summaryrefslogtreecommitdiff
path: root/src/components/Flashcard
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-05-29 14:08:02 +0700
committerpolwex <polwex@sortug.com>2025-05-29 14:08:02 +0700
commitf243847216279cbd43879de8b5ef6dcceb3a2f1d (patch)
tree1e0be878f164d327762c7bc54f37077d9410dafe /src/components/Flashcard
parent4ed3994fb0f6a2a09eb6ac433a62daee2fc01686 (diff)
lets see
Diffstat (limited to 'src/components/Flashcard')
-rw-r--r--src/components/Flashcard/StudyCard.tsx265
-rw-r--r--src/components/Flashcard/StudySession.tsx229
-rw-r--r--src/components/Flashcard/cards.css55
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 {