diff options
author | polwex <polwex@sortug.com> | 2025-05-29 14:08:02 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-05-29 14:08:02 +0700 |
commit | f243847216279cbd43879de8b5ef6dcceb3a2f1d (patch) | |
tree | 1e0be878f164d327762c7bc54f37077d9410dafe /src/components/Flashcard | |
parent | 4ed3994fb0f6a2a09eb6ac433a62daee2fc01686 (diff) |
lets see
Diffstat (limited to 'src/components/Flashcard')
-rw-r--r-- | src/components/Flashcard/StudyCard.tsx | 265 | ||||
-rw-r--r-- | src/components/Flashcard/StudySession.tsx | 229 | ||||
-rw-r--r-- | src/components/Flashcard/cards.css | 55 |
3 files changed, 549 insertions, 0 deletions
diff --git a/src/components/Flashcard/StudyCard.tsx b/src/components/Flashcard/StudyCard.tsx new file mode 100644 index 0000000..4e554b4 --- /dev/null +++ b/src/components/Flashcard/StudyCard.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { CardResponse } from "@/lib/types/cards"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { processReview, gradeCard } from "@/actions/srs"; +import "./cards.css"; + +interface StudyCardProps { + card: CardResponse; + userId: number; + onComplete: (newCard: CardResponse) => void; + onSkip?: () => void; +} + +export default function StudyCard({ card, userId, onComplete, onSkip }: StudyCardProps) { + const [isFlipped, setIsFlipped] = useState(false); + const [startTime, setStartTime] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset the timer when a new card is shown + useEffect(() => { + setIsFlipped(false); + setStartTime(Date.now()); + }, [card.id]); + + // Toggle card flip + const flipCard = () => { + if (!isFlipped) { + setIsFlipped(true); + } + }; + + // Calculate time spent on card in milliseconds + const getReviewTime = () => { + return Date.now() - startTime; + }; + + // Handle card grading (Good/Again) + const handleGrade = async (isCorrect: boolean) => { + if (isSubmitting) return; + + setIsSubmitting(true); + + try { + const result = await gradeCard(userId, card.id, isCorrect); + + if ('error' in result) { + console.error("Error grading card:", result.error); + } else { + onComplete(result as CardResponse); + } + } catch (error) { + console.error("Error processing review:", error); + } finally { + setIsSubmitting(false); + } + }; + + // Handle detailed grading with accuracy level + const handleDetailedGrade = async (accuracy: number) => { + if (isSubmitting) return; + + setIsSubmitting(true); + + try { + const reviewTime = getReviewTime(); + const result = await processReview(userId, card.id, accuracy, reviewTime); + + if ('error' in result) { + console.error("Error processing review:", result.error); + } else { + onComplete(result as CardResponse); + } + } catch (error) { + console.error("Error processing review:", error); + } finally { + setIsSubmitting(false); + } + }; + + // Calculate progress percentage for the card + const getProgressPercentage = () => { + const { interval, easeFactor } = card.progress; + // Assuming max interval is 365 days and max ease factor is 4.0 + const intervalProgress = Math.min(interval / 365, 1) * 70; // 70% weight to interval + const easeProgress = Math.min((easeFactor - 1) / 3, 1) * 30; // 30% weight to ease factor + return intervalProgress + easeProgress; + }; + + // Format content based on card type + const formatCardContent = (content: string, isBack: boolean = false) => { + // You can add more sophisticated formatting here based on card type + return content; + }; + + // Render IPA pronunciation if available + const renderIPA = () => { + if (card.expression.ipa && card.expression.ipa.length > 0) { + return ( + <div className="text-gray-500 text-sm mt-2"> + /{card.expression.ipa[0].ipa}/ + </div> + ); + } + return null; + }; + + // Render senses/meanings if available + const renderSenses = () => { + if (card.expression.senses && card.expression.senses.length > 0) { + return ( + <div className="mt-4"> + {card.expression.senses.map((sense, index) => ( + <div key={index} className="mb-3"> + {sense.pos && <span className="text-xs font-medium text-blue-600 mr-2">{sense.pos}</span>} + {sense.senses && sense.senses.map((subsense, i) => ( + <div key={i} className="mt-1"> + {subsense.glosses && subsense.glosses.map((gloss, j) => ( + <div key={j} className="text-sm">{j+1}. {gloss}</div> + ))} + </div> + ))} + </div> + ))} + </div> + ); + } + return null; + }; + + // Show bookmarked status if applicable + const renderBookmarked = () => { + if (card.expression.isBookmarked) { + return <div className="absolute top-2 right-2 text-yellow-500">★</div>; + } + return null; + }; + + return ( + <div className="flex flex-col items-center"> + <div className={cn("flashcard-container", { flipped: isFlipped })} onClick={flipCard}> + <div className="flashcard"> + {/* Front of card */} + <div className="flashcard-front"> + <Card className="w-full h-full flex flex-col justify-center items-center p-6 relative"> + {renderBookmarked()} + <div className="text-2xl font-bold">{card.expression.spelling}</div> + {!isFlipped && renderIPA()} + <div className="mt-4 text-lg">{formatCardContent(card.text)}</div> + {card.note && <div className="mt-2 text-sm text-gray-500">{card.note}</div>} + {!isFlipped && ( + <div className="mt-6 text-sm text-gray-400"> + Click to flip + </div> + )} + </Card> + </div> + + {/* Back of card */} + <div className="flashcard-back"> + <Card className="w-full h-full flex flex-col justify-between p-6 relative"> + {renderBookmarked()} + <div> + <div className="text-2xl font-bold">{card.expression.spelling}</div> + {renderIPA()} + <div className="mt-4 text-lg">{formatCardContent(card.text, true)}</div> + {card.note && <div className="mt-2 text-sm text-gray-500">{card.note}</div>} + {renderSenses()} + </div> + + <div className="flex flex-col mt-6"> + <div className="text-sm text-gray-500 mb-2"> + How well did you remember this? + </div> + <div className="flex justify-between gap-2"> + <Button + variant="destructive" + onClick={() => handleGrade(false)} + disabled={isSubmitting} + className="flex-1" + > + Again + </Button> + <Button + variant="default" + onClick={() => handleGrade(true)} + disabled={isSubmitting} + className="flex-1" + > + Good + </Button> + </div> + + {/* Optional: Detailed grading */} + <div className="grid grid-cols-4 gap-2 mt-3"> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.2)} + disabled={isSubmitting} + className="text-red-500" + > + Forgot + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.6)} + disabled={isSubmitting} + className="text-orange-500" + > + Hard + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.8)} + disabled={isSubmitting} + className="text-green-500" + > + Good + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(1.0)} + disabled={isSubmitting} + className="text-blue-500" + > + Easy + </Button> + </div> + </div> + </Card> + </div> + </div> + </div> + + {/* Progress bar */} + <div className="w-full mt-4"> + <Progress value={getProgressPercentage()} className="h-2" /> + <div className="flex justify-between text-xs text-gray-500 mt-1"> + <span>Interval: {card.progress.interval} days</span> + <span>Ease: {card.progress.easeFactor.toFixed(1)}</span> + </div> + </div> + + {/* Skip button */} + {onSkip && ( + <Button + variant="ghost" + onClick={onSkip} + className="mt-4" + disabled={isSubmitting} + > + Skip + </Button> + )} + </div> + ); +}
\ No newline at end of file diff --git a/src/components/Flashcard/StudySession.tsx b/src/components/Flashcard/StudySession.tsx new file mode 100644 index 0000000..1f79e09 --- /dev/null +++ b/src/components/Flashcard/StudySession.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { DeckResponse, CardResponse } from "@/lib/types/cards"; +import { startStudySession, getUserStudyStats } from "@/actions/srs"; +import StudyCard from "./StudyCard"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +interface StudySessionProps { + userId: number; + lessonId: number; + initialData?: DeckResponse; +} + +export default function StudySession({ userId, lessonId, initialData }: StudySessionProps) { + const [deckData, setDeckData] = useState<DeckResponse | null>(initialData || null); + const [currentCardIndex, setCurrentCardIndex] = useState(0); + const [reviewedCards, setReviewedCards] = useState<CardResponse[]>([]); + const [isLoading, setIsLoading] = useState(!initialData); + const [isCompleted, setIsCompleted] = useState(false); + const [stats, setStats] = useState<any>(null); + const [error, setError] = useState<string | null>(null); + + // Load the deck data if not provided + useEffect(() => { + if (!initialData) { + loadDeck(); + } + + // Load user stats + loadStats(); + }, []); + + // Load deck data + const loadDeck = async () => { + setIsLoading(true); + setError(null); + + try { + const result = await startStudySession(userId, lessonId, true); + + if ('error' in result) { + setError(result.error); + setDeckData(null); + } else { + setDeckData(result); + } + } catch (error) { + console.error("Error loading deck:", error); + setError("Failed to load study session. Please try again later."); + } finally { + setIsLoading(false); + } + }; + + // Load user stats + const loadStats = async () => { + try { + const userStats = await getUserStudyStats(userId); + setStats(userStats); + } catch (error) { + console.error("Error loading stats:", error); + } + }; + + // Handle card completion + const handleCardComplete = (updatedCard: CardResponse) => { + // Add to reviewed cards + setReviewedCards(prev => [...prev, updatedCard]); + + // Move to next card + if (deckData && currentCardIndex < deckData.cards.length - 1) { + setCurrentCardIndex(currentCardIndex + 1); + } else { + // End of deck + setIsCompleted(true); + } + + // Refresh stats + loadStats(); + }; + + // Skip current card + const handleSkip = () => { + if (deckData && currentCardIndex < deckData.cards.length - 1) { + setCurrentCardIndex(currentCardIndex + 1); + } + }; + + // Restart session + const handleRestart = () => { + setCurrentCardIndex(0); + setReviewedCards([]); + setIsCompleted(false); + loadDeck(); + }; + + // Calculate completion percentage + const getCompletionPercentage = () => { + if (!deckData) return 0; + return (reviewedCards.length / deckData.cards.length) * 100; + }; + + // Get current card + const getCurrentCard = (): CardResponse | null => { + if (!deckData || !deckData.cards || deckData.cards.length === 0) return null; + return deckData.cards[currentCardIndex]; + }; + + // Render loading state + if (isLoading) { + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6"> + <div className="space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-[400px] w-full" /> + <div className="flex justify-between"> + <Skeleton className="h-10 w-24" /> + <Skeleton className="h-10 w-24" /> + </div> + </div> + </Card> + </div> + ); + } + + // Render error state + if (error) { + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6 text-center"> + <div className="text-red-500 mb-4">{error}</div> + <Button onClick={loadDeck}>Retry</Button> + </Card> + </div> + ); + } + + // Render completion state + if (isCompleted || !getCurrentCard()) { + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6"> + <div className="text-center"> + <h2 className="text-2xl font-bold mb-4">Study Session Completed!</h2> + <div className="mb-6"> + <p className="text-lg">You've reviewed {reviewedCards.length} cards.</p> + {stats && ( + <div className="mt-4 text-sm text-gray-600"> + <p>Total cards: {stats.totalCards}</p> + <p>Mastered cards: {stats.masteredCards}</p> + <p>Due cards remaining: {stats.dueCards}</p> + </div> + )} + </div> + <div className="flex justify-center gap-4"> + <Button onClick={handleRestart}>Start New Session</Button> + <Button variant="outline" onClick={() => window.history.back()}> + Back to Lessons + </Button> + </div> + </div> + </Card> + </div> + ); + } + + // Render study session + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <div className="mb-6"> + <div className="flex justify-between items-center mb-2"> + <h2 className="text-xl font-bold"> + {deckData?.lesson.name} + </h2> + <div className="text-sm text-gray-500"> + {reviewedCards.length} / {deckData?.cards.length} cards + </div> + </div> + <Progress value={getCompletionPercentage()} className="h-2" /> + </div> + + <StudyCard + card={getCurrentCard()!} + userId={userId} + onComplete={handleCardComplete} + onSkip={handleSkip} + /> + + <div className="mt-6 flex justify-between"> + <Button variant="ghost" onClick={() => window.history.back()}> + Exit + </Button> + <Button variant="outline" onClick={handleSkip}> + Skip + </Button> + </div> + + {stats && ( + <div className="mt-8 p-4 bg-gray-50 rounded-lg"> + <h3 className="font-medium mb-2">Your Progress</h3> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.totalCards}</div> + <div className="text-xs text-gray-500">Total Cards</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.masteredCards}</div> + <div className="text-xs text-gray-500">Mastered</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.dueCards}</div> + <div className="text-xs text-gray-500">Due Today</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.streakDays}</div> + <div className="text-xs text-gray-500">Day Streak</div> + </div> + </div> + </div> + )} + </div> + ); +}
\ No newline at end of file diff --git a/src/components/Flashcard/cards.css b/src/components/Flashcard/cards.css index 2f75ad6..2d80051 100644 --- a/src/components/Flashcard/cards.css +++ b/src/components/Flashcard/cards.css @@ -20,6 +20,61 @@ body { transform: rotateY(180deg); } +/* Flashcard styles */ +.flashcard-container { + perspective: 1000px; + width: 100%; + max-width: 600px; + height: 400px; + cursor: pointer; + margin: 0 auto; +} + +.flashcard { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.6s; + transform-style: preserve-3d; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + border-radius: 0.5rem; +} + +.flipped .flashcard { + transform: rotateY(180deg); +} + +.flashcard-front, +.flashcard-back { + position: absolute; + width: 100%; + height: 100%; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + border-radius: 0.5rem; + overflow: hidden; +} + +.flashcard-front { + z-index: 2; +} + +.flashcard-back { + transform: rotateY(180deg); +} + +/* Card hover effect */ +.flashcard-container:hover .flashcard { + box-shadow: 0 8px 16px rgba(0,0,0,0.2); +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .flashcard-container { + height: 350px; + } +} + /* Slide animations */ @keyframes slide-in-right { from { |