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:
2026-03-14 11:34:46 +08:00
parent af684a15ce
commit 6213dd2338
7 changed files with 42 additions and 179 deletions

View File

@@ -1,114 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Layers } from "lucide-react";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { ActionOutputCardStats } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface DeckWithStats extends ActionOutputDeck {
stats?: ActionOutputCardStats;
}
interface DeckSelectorProps {
decks: ActionOutputDeck[];
deckStats: Map<number, ActionOutputCardStats | undefined>;
}
const DeckSelector: React.FC<DeckSelectorProps> = ({ decks, deckStats }) => {
const t = useTranslations("memorize.deck_selector");
const router = useRouter();
const formatCardStats = (stats: ActionOutputCardStats | undefined): string => {
if (!stats) return t("noCards");
const parts: string[] = [];
if (stats.new > 0) parts.push(`${t("new")}: ${stats.new}`);
if (stats.learning > 0) parts.push(`${t("learning")}: ${stats.learning}`);
if (stats.review > 0) parts.push(`${t("review")}: ${stats.review}`);
if (stats.due > 0) parts.push(`${t("due")}: ${stats.due}`);
return parts.length > 0 ? parts.join(" • ") : t("noCards");
};
const getDueCount = (deckId: number): number => {
const stats = deckStats.get(deckId);
return stats?.due ?? 0;
};
return (
<PageLayout>
{decks.length === 0 ? (
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noDecks")}
</h1>
<Link href="/decks">
<PrimaryButton className="px-6 py-2">
{t("goToDecks")}
</PrimaryButton>
</Link>
</div>
) : (
<>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectDeck")}
</h1>
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{decks
.toSorted((a, b) => a.id - b.id)
.map((deck) => {
const stats = deckStats.get(deck.id);
const dueCount = getDueCount(deck.id);
return (
<div
key={deck.id}
onClick={() =>
router.push(`/memorize?deck_id=${deck.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
<div className="shrink-0">
<Layers className="text-gray-600 w-5 h-5" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900">
{deck.name}
</div>
<div className="text-sm text-gray-500">
{formatCardStats(stats)}
</div>
</div>
{dueCount > 0 && (
<div className="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">
{dueCount}
</div>
)}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
);
})}
</div>
</>
)}
</PageLayout>
);
};
export { DeckSelector };

View File

@@ -1,57 +0,0 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { DeckSelector } from "./DeckSelector";
import { Memorize } from "./Memorize";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionGetCardStats } from "@/modules/card/card-action";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ deck_id?: string; }>;
}) {
const deckIdParam = (await searchParams).deck_id;
const t = await getTranslations("memorize.page");
const deckId = deckIdParam
? isNonNegativeInteger(deckIdParam)
? parseInt(deckIdParam)
: null
: null;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
if (!deckId) {
const decksResult = await actionGetDecksByUserId(session.user.id);
const decks = decksResult.data ?? [];
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
for (const deck of decks) {
const statsResult = await actionGetCardStats({ deckId: deck.id });
if (statsResult.success && statsResult.data) {
deckStats.set(deck.id, statsResult.data);
}
}
return (
<DeckSelector
decks={decks}
deckStats={deckStats}
/>
);
}
const decksResult = await actionGetDecksByUserId(session.user.id);
const deck = decksResult.data?.find(d => d.id === deckId);
if (!deck) {
redirect("/memorize");
}
return <Memorize deckId={deckId} deckName={deck.name} />;
}

View File

@@ -2,7 +2,7 @@
import { ArrowLeft, Plus, RotateCcw } from "lucide-react"; import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AddCardModal } from "./AddCardModal"; import { AddCardModal } from "./AddCardModal";
import { CardItem } from "./CardItem"; import { CardItem } from "./CardItem";
import { useTranslations } from "next-intl"; 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"> <div className="flex items-center gap-2">
<PrimaryButton <PrimaryButton
onClick={() => { onClick={() => {
redirect(`/memorize?deck_id=${deckId}`); router.push(`/decks/${deckId}/learn`);
}} }}
> >
{t("memorize")} {t("memorize")}

View File

@@ -9,11 +9,11 @@ import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modu
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action"; import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button"; import { LightButton } from "@/design-system/base/button";
import { CardType } from "../../../../generated/prisma/enums"; import { CardType } from "../../../../../generated/prisma/enums";
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview"; import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
const myFont = localFont({ const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
}); });
interface MemorizeProps { interface MemorizeProps {
@@ -207,7 +207,7 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
<PageLayout> <PageLayout>
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p> <p className="text-red-600 mb-4">{error}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2"> <LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")} {t("backToDecks")}
</LightButton> </LightButton>
</div> </div>
@@ -224,7 +224,7 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
</div> </div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2> <h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p> <p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2"> <LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")} {t("backToDecks")}
</LightButton> </LightButton>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { CardType } from "../../../../generated/prisma/enums"; import { CardType } from "../../../../../generated/prisma/enums";
import { SM2_CONFIG } from "@/modules/card/card-service-dto"; import { SM2_CONFIG } from "@/modules/card/card-service-dto";
export interface CardPreview { export interface CardPreview {

View 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} />;
}

View File

@@ -73,7 +73,7 @@ export default async function HomePage() {
color="#dd7486" color="#dd7486"
></LinkArea> ></LinkArea>
<LinkArea <LinkArea
href="/memorize" href="/decks"
name={t("memorize.name")} name={t("memorize.name")}
description={t("memorize.description")} description={t("memorize.description")}
color="#cc9988" color="#cc9988"