From f243847216279cbd43879de8b5ef6dcceb3a2f1d Mon Sep 17 00:00:00 2001 From: polwex Date: Thu, 29 May 2025 14:08:02 +0700 Subject: lets see --- src/components/Flashcard/StudyCard.tsx | 265 ++++++++++++++++++++++++++++++ src/components/Flashcard/StudySession.tsx | 229 ++++++++++++++++++++++++++ src/components/Flashcard/cards.css | 55 +++++++ 3 files changed, 549 insertions(+) create mode 100644 src/components/Flashcard/StudyCard.tsx create mode 100644 src/components/Flashcard/StudySession.tsx (limited to 'src/components') 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 ( +
+ /{card.expression.ipa[0].ipa}/ +
+ ); + } + return null; + }; + + // Render senses/meanings if available + const renderSenses = () => { + if (card.expression.senses && card.expression.senses.length > 0) { + return ( +
+ {card.expression.senses.map((sense, index) => ( +
+ {sense.pos && {sense.pos}} + {sense.senses && sense.senses.map((subsense, i) => ( +
+ {subsense.glosses && subsense.glosses.map((gloss, j) => ( +
{j+1}. {gloss}
+ ))} +
+ ))} +
+ ))} +
+ ); + } + return null; + }; + + // Show bookmarked status if applicable + const renderBookmarked = () => { + if (card.expression.isBookmarked) { + return
; + } + return null; + }; + + return ( +
+
+
+ {/* Front of card */} +
+ + {renderBookmarked()} +
{card.expression.spelling}
+ {!isFlipped && renderIPA()} +
{formatCardContent(card.text)}
+ {card.note &&
{card.note}
} + {!isFlipped && ( +
+ Click to flip +
+ )} +
+
+ + {/* Back of card */} +
+ + {renderBookmarked()} +
+
{card.expression.spelling}
+ {renderIPA()} +
{formatCardContent(card.text, true)}
+ {card.note &&
{card.note}
} + {renderSenses()} +
+ +
+
+ How well did you remember this? +
+
+ + +
+ + {/* Optional: Detailed grading */} +
+ + + + +
+
+
+
+
+
+ + {/* Progress bar */} +
+ +
+ Interval: {card.progress.interval} days + Ease: {card.progress.easeFactor.toFixed(1)} +
+
+ + {/* Skip button */} + {onSkip && ( + + )} +
+ ); +} \ 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(initialData || null); + const [currentCardIndex, setCurrentCardIndex] = useState(0); + const [reviewedCards, setReviewedCards] = useState([]); + const [isLoading, setIsLoading] = useState(!initialData); + const [isCompleted, setIsCompleted] = useState(false); + const [stats, setStats] = useState(null); + const [error, setError] = useState(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 ( +
+ +
+ + +
+ + +
+
+
+
+ ); + } + + // Render error state + if (error) { + return ( +
+ +
{error}
+ +
+
+ ); + } + + // Render completion state + if (isCompleted || !getCurrentCard()) { + return ( +
+ +
+

Study Session Completed!

+
+

You've reviewed {reviewedCards.length} cards.

+ {stats && ( +
+

Total cards: {stats.totalCards}

+

Mastered cards: {stats.masteredCards}

+

Due cards remaining: {stats.dueCards}

+
+ )} +
+
+ + +
+
+
+
+ ); + } + + // Render study session + return ( +
+
+
+

+ {deckData?.lesson.name} +

+
+ {reviewedCards.length} / {deckData?.cards.length} cards +
+
+ +
+ + + +
+ + +
+ + {stats && ( +
+

Your Progress

+
+
+
{stats.totalCards}
+
Total Cards
+
+
+
{stats.masteredCards}
+
Mastered
+
+
+
{stats.dueCards}
+
Due Today
+
+
+
{stats.streakDays}
+
Day Streak
+
+
+
+ )} +
+ ); +} \ 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 { -- cgit v1.2.3