refactor: move memorize feature to /decks/[deck_id]/learn route
- Delete (features)/memorize directory - Create /decks/[deck_id]/learn with Memorize component and page - Update InDeck.tsx to navigate to new learn route - Fix homepage memorize link to point to /decks
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AddCardModal } from "./AddCardModal";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -99,7 +99,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?deck_id=${deckId}`);
|
||||
router.push(`/decks/${deckId}/learn`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
|
||||
371
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
371
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import localFont from "next/font/local";
|
||||
import { Layers, Check, Clock, Sparkles } from "lucide-react";
|
||||
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
||||
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { CardType } from "../../../../../generated/prisma/enums";
|
||||
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
|
||||
|
||||
const myFont = localFont({
|
||||
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||
});
|
||||
|
||||
interface MemorizeProps {
|
||||
deckId: number;
|
||||
deckName: string;
|
||||
}
|
||||
|
||||
type ReviewEase = 1 | 2 | 3 | 4;
|
||||
|
||||
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
||||
const t = useTranslations("memorize.review");
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showAnswer, setShowAnswer] = useState(false);
|
||||
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
const loadCards = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await actionGetCardsForReview({ deckId, limit: 50 });
|
||||
if (!ignore) {
|
||||
if (result.success && result.data) {
|
||||
setCards(result.data);
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
setLastScheduled(null);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadCards();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [deckId]);
|
||||
|
||||
const getCurrentCard = (): ActionOutputCardWithNote | null => {
|
||||
return cards[currentIndex] ?? null;
|
||||
};
|
||||
|
||||
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
|
||||
return card.note.flds.split('\x1f');
|
||||
};
|
||||
|
||||
const handleShowAnswer = useCallback(() => {
|
||||
setShowAnswer(true);
|
||||
}, []);
|
||||
|
||||
const handleAnswer = useCallback((ease: ReviewEase) => {
|
||||
const card = getCurrentCard();
|
||||
if (!card) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await actionAnswerCard({
|
||||
cardId: BigInt(card.id),
|
||||
ease,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setLastScheduled(result.data.scheduled);
|
||||
|
||||
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
|
||||
setCards(remainingCards);
|
||||
|
||||
if (remainingCards.length === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else if (currentIndex >= remainingCards.length) {
|
||||
setCurrentIndex(remainingCards.length - 1);
|
||||
}
|
||||
|
||||
setShowAnswer(false);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}, [cards, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showAnswer) {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleShowAnswer();
|
||||
}
|
||||
} else {
|
||||
if (e.key === "1") {
|
||||
e.preventDefault();
|
||||
handleAnswer(1);
|
||||
} else if (e.key === "2") {
|
||||
e.preventDefault();
|
||||
handleAnswer(2);
|
||||
} else if (e.key === "3" || e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAnswer(3);
|
||||
} else if (e.key === "4") {
|
||||
e.preventDefault();
|
||||
handleAnswer(4);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [showAnswer, handleShowAnswer, handleAnswer]);
|
||||
|
||||
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
|
||||
const now = new Date();
|
||||
const nextReview = new Date(scheduled.nextReviewDate);
|
||||
const diffMs = nextReview.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return t("now");
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t("lessThanMinute");
|
||||
if (diffMins < 60) return t("inMinutes", { count: diffMins });
|
||||
if (diffHours < 24) return t("inHours", { count: diffHours });
|
||||
if (diffDays < 30) return t("inDays", { count: diffDays });
|
||||
return t("inMonths", { count: Math.floor(diffDays / 30) });
|
||||
};
|
||||
|
||||
const formatInterval = (ivl: number): string => {
|
||||
if (ivl < 1) return t("minutes");
|
||||
if (ivl < 30) return t("days", { count: ivl });
|
||||
return t("months", { count: Math.floor(ivl / 30) });
|
||||
};
|
||||
|
||||
const getCardTypeLabel = (type: CardType): string => {
|
||||
switch (type) {
|
||||
case CardType.NEW:
|
||||
return t("cardTypeNew");
|
||||
case CardType.LEARNING:
|
||||
return t("cardTypeLearning");
|
||||
case CardType.REVIEW:
|
||||
return t("cardTypeReview");
|
||||
case CardType.RELEARNING:
|
||||
return t("cardTypeRelearning");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getCardTypeColor = (type: CardType): string => {
|
||||
switch (type) {
|
||||
case CardType.NEW:
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case CardType.LEARNING:
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case CardType.REVIEW:
|
||||
return "bg-green-100 text-green-700";
|
||||
case CardType.RELEARNING:
|
||||
return "bg-purple-100 text-purple-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">{t("loading")}</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
||||
{t("backToDecks")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-green-500 mb-4">
|
||||
<Check className="w-16 h-16 mx-auto" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
||||
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
||||
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
||||
{t("backToDecks")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const currentCard = getCurrentCard()!;
|
||||
const fields = getNoteFields(currentCard);
|
||||
const front = fields[0] ?? "";
|
||||
const back = fields[1] ?? "";
|
||||
|
||||
const cardPreview: CardPreview = {
|
||||
type: currentCard.type,
|
||||
ivl: currentCard.ivl,
|
||||
factor: currentCard.factor,
|
||||
left: currentCard.left,
|
||||
};
|
||||
const previewIntervals = calculatePreviewIntervals(cardPreview);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Layers className="w-5 h-5" />
|
||||
<span className="font-medium">{deckName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getCardTypeColor(currentCard.type)}`}>
|
||||
{getCardTypeLabel(currentCard.type)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastScheduled && (
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{t("nextReview")}: {formatNextReview(lastScheduled)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{front}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
<>
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{back}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
|
||||
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
|
||||
<span>•</span>
|
||||
<span>{t("ease")}: {currentCard.factor / 10}%</span>
|
||||
<span>•</span>
|
||||
<span>{t("lapses")}: {currentCard.lapses}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{!showAnswer ? (
|
||||
<LightButton
|
||||
onClick={handleShowAnswer}
|
||||
disabled={isPending}
|
||||
className="px-8 py-3 text-lg rounded-full"
|
||||
>
|
||||
{t("showAnswer")}
|
||||
<span className="ml-2 text-xs opacity-60">Space</span>
|
||||
</LightButton>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
onClick={() => handleAnswer(1)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-5 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||
>
|
||||
<span className="font-medium">{t("again")}</span>
|
||||
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.again)}</span>
|
||||
<span className="text-xs opacity-50 mt-1">1</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(2)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-5 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||
>
|
||||
<span className="font-medium">{t("hard")}</span>
|
||||
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.hard)}</span>
|
||||
<span className="text-xs opacity-50 mt-1">2</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(3)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-5 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50 min-w-[80px] ring-2 ring-green-300"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">{t("good")}</span>
|
||||
<Sparkles className="w-3 h-3 opacity-60" />
|
||||
</div>
|
||||
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.good)}</span>
|
||||
<span className="text-xs opacity-50 mt-1">3/Space</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(4)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-5 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||
>
|
||||
<span className="font-medium">{t("easy")}</span>
|
||||
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.easy)}</span>
|
||||
<span className="text-xs opacity-50 mt-1">4</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { Memorize };
|
||||
85
src/app/decks/[deck_id]/learn/interval-preview.ts
Normal file
85
src/app/decks/[deck_id]/learn/interval-preview.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { CardType } from "../../../../../generated/prisma/enums";
|
||||
import { SM2_CONFIG } from "@/modules/card/card-service-dto";
|
||||
|
||||
export interface CardPreview {
|
||||
type: CardType;
|
||||
ivl: number;
|
||||
factor: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface PreviewIntervals {
|
||||
again: number;
|
||||
hard: number;
|
||||
good: number;
|
||||
easy: number;
|
||||
}
|
||||
|
||||
function calculateReviewIntervals(ivl: number, factor: number): PreviewIntervals {
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
return {
|
||||
again: Math.max(1, Math.floor(ivl * SM2_CONFIG.NEW_INTERVAL)) * MINUTES_PER_DAY,
|
||||
hard: Math.floor(ivl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||
good: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||
easy: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateNewCardIntervals(): PreviewIntervals {
|
||||
const steps = SM2_CONFIG.LEARNING_STEPS;
|
||||
|
||||
return {
|
||||
again: steps[0],
|
||||
hard: steps.length >= 2 ? (steps[0] + steps[1]) / 2 : steps[0],
|
||||
good: steps.length >= 2 ? steps[1] : SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440,
|
||||
easy: SM2_CONFIG.EASY_INTERVAL * 1440,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateLearningIntervals(left: number, isRelearning: boolean): PreviewIntervals {
|
||||
const steps = isRelearning ? SM2_CONFIG.RELEARNING_STEPS : SM2_CONFIG.LEARNING_STEPS;
|
||||
const stepIndex = Math.floor(left % 1000);
|
||||
|
||||
const again = steps[0] ?? 1;
|
||||
|
||||
let hard: number;
|
||||
if (stepIndex === 0 && steps.length >= 2) {
|
||||
const step0 = steps[0] ?? 1;
|
||||
const step1 = steps[1] ?? step0;
|
||||
hard = (step0 + step1) / 2;
|
||||
} else {
|
||||
hard = steps[stepIndex] ?? steps[0] ?? 1;
|
||||
}
|
||||
|
||||
let good: number;
|
||||
if (stepIndex < steps.length - 1) {
|
||||
good = steps[stepIndex + 1] ?? steps[0] ?? 1;
|
||||
} else {
|
||||
good = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
|
||||
}
|
||||
|
||||
const easy = SM2_CONFIG.GRADUATING_INTERVAL_EASY * 1440;
|
||||
|
||||
return { again, hard, good, easy };
|
||||
}
|
||||
|
||||
export function calculatePreviewIntervals(card: CardPreview): PreviewIntervals {
|
||||
switch (card.type) {
|
||||
case CardType.NEW:
|
||||
return calculateNewCardIntervals();
|
||||
case CardType.LEARNING:
|
||||
return calculateLearningIntervals(card.left, false);
|
||||
case CardType.RELEARNING:
|
||||
return calculateLearningIntervals(card.left, true);
|
||||
case CardType.REVIEW:
|
||||
default:
|
||||
return calculateReviewIntervals(card.ivl, card.factor);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPreviewInterval(minutes: number): string {
|
||||
if (minutes < 1) return "<1";
|
||||
if (minutes < 60) return `${Math.round(minutes)}`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
|
||||
return `${Math.round(minutes / 1440)}d`;
|
||||
}
|
||||
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||
import { Memorize } from "./Memorize";
|
||||
|
||||
export default async function LearnPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ deck_id: string }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { deck_id } = await params;
|
||||
const deckId = Number(deck_id);
|
||||
|
||||
if (!deckId) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const deckInfo = (await actionGetDeckById({ deckId })).data;
|
||||
|
||||
if (!deckInfo) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === deckInfo.userId;
|
||||
const isPublic = deckInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isOwner && !isPublic) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
return <Memorize deckId={deckId} deckName={deckInfo.name} />;
|
||||
}
|
||||
Reference in New Issue
Block a user