From 12e502313b7435225dd1e094af75bba117367bcf Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Fri, 13 Mar 2026 15:07:49 +0800 Subject: [PATCH] feat(memorize): enhance review UI with dynamic intervals and keyboard shortcuts - Add keyboard shortcuts: Space/Enter to show answer, 1-4 for responses - Display dynamic preview intervals on answer buttons (1m, 6m, 4d, etc.) - Add card type indicator (New/Learning/Review/Relearning) with color badges - Highlight Good button as recommended option with ring and icon - Show keyboard hint on Show Answer button Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/app/(features)/memorize/Memorize.tsx | 119 +++++++++++++++++++---- 1 file changed, 102 insertions(+), 17 deletions(-) diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx index 6ba561f..a634d45 100644 --- a/src/app/(features)/memorize/Memorize.tsx +++ b/src/app/(features)/memorize/Memorize.tsx @@ -1,14 +1,16 @@ "use client"; -import { useState, useEffect, useTransition } from "react"; +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 } from "lucide-react"; +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", @@ -70,11 +72,11 @@ const Memorize: React.FC = ({ deckId, deckName }) => { return card.note.flds.split('\x1f'); }; - const handleShowAnswer = () => { + const handleShowAnswer = useCallback(() => { setShowAnswer(true); - }; + }, []); - const handleAnswer = (ease: ReviewEase) => { + const handleAnswer = useCallback((ease: ReviewEase) => { const card = getCurrentCard(); if (!card) return; @@ -101,7 +103,39 @@ const Memorize: React.FC = ({ deckId, deckName }) => { 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(); @@ -127,6 +161,36 @@ const Memorize: React.FC = ({ deckId, deckName }) => { 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 ( @@ -173,6 +237,14 @@ const Memorize: React.FC = ({ deckId, deckName }) => { 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 (
@@ -180,8 +252,13 @@ const Memorize: React.FC = ({ deckId, deckName }) => { {deckName}
-
- {t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })} +
+ + {getCardTypeLabel(currentCard.type)} + + + {t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })} +
@@ -238,43 +315,51 @@ const Memorize: React.FC = ({ deckId, deckName }) => { className="px-8 py-3 text-lg rounded-full" > {t("showAnswer")} + Space ) : (
)}