mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-26 05:25:37 +07:00
243 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|