Files
nihonbuzz-academy/resources/js/Pages/Srs/Practice.tsx

243 lines
14 KiB
TypeScript

import DashboardLayout from '@/Layouts/DashboardLayout';
import { Head, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card } from '@/Components/ui/card';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, X, Volume2, RotateCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Progress } from '@/Components/ui/progress';
interface ReviewItem {
type: 'review' | 'new';
id: string; // Vocabulary ID
srs_id?: string;
word: string;
reading: string;
meaning: string;
audio_url?: string;
}
export default function SrsPractice({ items }: { items: ReviewItem[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [sessionQueue, setSessionQueue] = useState(items);
const [completedCount, setCompletedCount] = useState(0);
const currentItem = sessionQueue[currentIndex];
const isFinished = !currentItem;
const progress = items.length > 0 ? (completedCount / items.length) * 100 : 0;
const handleFlip = useCallback(() => {
if (!isFlipped && !isFinished) {
setIsFlipped(true);
if (currentItem?.audio_url) {
new Audio(currentItem.audio_url).play().catch(() => { });
}
}
}, [isFlipped, isFinished, currentItem]);
const handleGrade = useCallback((grade: number) => {
if (!isFlipped || isFinished) return;
// Optimistic UI update
const itemToSubmit = currentItem;
// Submit in background
router.post(route('srs.store'), {
vocabulary_id: itemToSubmit.id,
grade: grade
}, {
preserveScroll: true,
preserveState: true, // Keep our local state intact
onSuccess: () => {
// Determine if we should requeue (Again/1) or remove
// For simplicity in this version, we move to next regardless,
// but real apps might requeue failed cards.
}
});
// Move to next
setIsFlipped(false);
setCompletedCount(prev => prev + 1);
setCurrentIndex(prev => prev + 1);
}, [isFlipped, isFinished, currentItem]);
// Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isFinished) return;
if (e.code === 'Space') {
e.preventDefault();
if (!isFlipped) handleFlip();
} else if (isFlipped) {
if (e.key === '1') handleGrade(1);
if (e.key === '2') handleGrade(2);
if (e.key === '3') handleGrade(3);
if (e.key === '4') handleGrade(4);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleFlip, handleGrade, isFinished, isFlipped]);
return (
<DashboardLayout header={
<div className="flex items-center justify-between w-full">
<div className="flex flex-col">
<h2 className="text-2xl font-black tracking-tight text-foreground">
SRS Practice
</h2>
<p className="text-xs text-muted-foreground font-medium">Power up your Japanese memory</p>
</div>
<div className="w-1/3 lg:w-1/4">
<div className="flex justify-between items-end mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-primary">Progress</span>
<span className="text-[10px] font-bold text-muted-foreground">{completedCount} / {items.length}</span>
</div>
<Progress value={progress} className="h-1.5 bg-primary/10" />
</div>
</div>
}>
<Head title="SRS Practice" />
<div className="max-w-4xl mx-auto w-full py-8 lg:py-12 flex flex-col items-center min-h-[70vh]">
<AnimatePresence mode="wait">
{isFinished ? (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="text-center w-full max-w-md"
>
<Card className="bg-card/40 backdrop-blur-2xl border-border/40 p-12 rounded-[3rem] shadow-2xl flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-2 bg-gradient-to-r from-transparent via-green-500 to-transparent opacity-50" />
<div className="h-24 w-24 bg-green-500/10 text-green-500 rounded-full flex items-center justify-center mb-8 ring-8 ring-green-500/5">
<Check size={48} strokeWidth={3} />
</div>
<h3 className="text-3xl font-black text-foreground mb-3">Otsukaresama! 🎉</h3>
<p className="text-muted-foreground font-medium mb-10">Sesi ulasan Anda telah selesai dengan luar biasa.</p>
<Button
onClick={() => router.visit(route('srs.index'))}
size="lg"
className="w-full rounded-[1.5rem] h-14 text-lg font-bold bg-primary hover:bg-primary/90 shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all"
>
Kembali ke Dashboard
</Button>
</Card>
</motion.div>
) : (
<div className="w-full max-w-2xl px-4 flex flex-col items-center">
{/* Flashcard Area */}
<div
className="w-full aspect-[16/10] sm:aspect-[16/9] perspective-2000 cursor-pointer group mb-12"
onClick={handleFlip}
>
<motion.div
className="relative w-full h-full transform-style-3d shadow-2xl rounded-[3rem]"
animate={{ rotateY: isFlipped ? 180 : 0 }}
transition={{ type: "spring", stiffness: 150, damping: 25 }}
>
{/* Front Side */}
<Card className="absolute w-full h-full backface-hidden flex flex-col items-center justify-center p-12 bg-card/40 backdrop-blur-3xl border border-white/20 dark:border-white/5 rounded-[3rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.1)]">
<div className="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-primary/10 border border-primary/20">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.2em]">
{currentItem.type === 'new' ? 'New Discovery' : 'Active Review'}
</span>
</div>
<div className="flex flex-col items-center gap-4">
<h1 className="text-7xl md:text-9xl font-black text-foreground tracking-tighter drop-shadow-sm">
{currentItem.word}
</h1>
<div className="mt-8 flex items-center gap-2 text-muted-foreground/40 font-bold text-xs uppercase tracking-widest animate-pulse">
<span>Klik untuk Membalik</span>
<RotateCw className="w-3 h-3" />
</div>
</div>
</Card>
{/* Back Side */}
<Card className="absolute w-full h-full backface-hidden rotate-y-180 flex flex-col items-center justify-center p-12 bg-card/60 backdrop-blur-3xl border border-primary/20 rounded-[3rem] shadow-2xl">
<div className="absolute top-8 left-1/2 -translate-x-1/2 border-b-4 border-primary/30 w-12 rounded-full" />
<div className="flex flex-col items-center text-center space-y-6 w-full">
<h2 className="text-5xl md:text-6xl font-black text-primary tracking-tight">
{currentItem.reading}
</h2>
<div className="h-px w-24 bg-border/50" />
<div className="space-y-2">
<p className="text-3xl md:text-4xl text-foreground font-black tracking-tight">
{currentItem.meaning}
</p>
<p className="text-sm font-medium text-muted-foreground/60 italic">Kosa Kata N5</p>
</div>
{currentItem.audio_url && (
<Button
variant="secondary"
size="lg"
onClick={(e) => {
e.stopPropagation();
new Audio(currentItem.audio_url!).play();
}}
className="mt-6 rounded-2xl h-14 w-14 p-0 bg-primary/10 text-primary hover:bg-primary hover:text-white transition-all shadow-lg shadow-primary/5"
>
<Volume2 size={24} />
</Button>
)}
</div>
</Card>
</motion.div>
</div>
{/* Controls */}
<div className={cn(
"flex flex-wrap justify-center gap-4 w-full transition-all duration-500",
isFlipped ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 translate-y-8 pointer-events-none"
)}>
{[
{ val: 1, label: 'Again', desc: 'Lupakan', color: 'red' },
{ val: 2, label: 'Hard', desc: 'Sulit', color: 'orange' },
{ val: 3, label: 'Good', desc: 'Bagus', color: 'blue' },
{ val: 4, label: 'Easy', desc: 'Mudah', color: 'green' }
].map((btn) => (
<Button
key={btn.val}
variant="outline"
className={cn(
"flex-1 min-w-[120px] h-20 flex flex-col gap-1 rounded-2xl border-2 transition-all hover:-translate-y-1",
btn.color === 'red' && "border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-600 hover:border-red-500/40",
btn.color === 'orange' && "border-orange-500/20 bg-orange-500/5 hover:bg-orange-500/10 text-orange-600 hover:border-orange-500/40",
btn.color === 'blue' && "border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-600 hover:border-blue-500/40",
btn.color === 'green' && "border-green-500/20 bg-green-500/5 hover:bg-green-500/10 text-green-600 hover:border-green-500/40"
)}
onClick={() => handleGrade(btn.val)}
>
<span className="text-base font-black uppercase tracking-wider">{btn.label}</span>
<span className="text-[10px] font-bold opacity-60 uppercase tracking-tighter">{btn.desc}</span>
</Button>
))}
</div>
{/* Keyboard Tips */}
<div className={cn(
"mt-8 text-center transition-opacity duration-1000",
isFlipped ? "opacity-40" : "opacity-0"
)}>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Tips: Gunakan angka <span className="text-foreground">1 - 4</span> di keyboard Anda
</p>
</div>
</div>
)}
</AnimatePresence>
</div>
</DashboardLayout>
);
}