feat: add reset deck progress feature for deck detail page
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { AddCardModal } from "./AddCardModal";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { PrimaryButton, CircleButton, LinkButton, LightButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action";
|
||||
import { Modal } from "@/design-system/overlay/modal";
|
||||
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard, actionResetDeckCards } from "@/modules/card/card-action";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -18,6 +19,8 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const [openResetModal, setResetModal] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
@@ -54,6 +57,24 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetDeck = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
const result = await actionResetDeckCards({ deckId });
|
||||
if (result.success) {
|
||||
toast.success(t("resetSuccess", { count: result.data?.count ?? 0 }));
|
||||
setResetModal(false);
|
||||
await refreshCards();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mb-6">
|
||||
@@ -84,13 +105,21 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
<>
|
||||
<LightButton
|
||||
onClick={() => setResetModal(true)}
|
||||
leftIcon={<RotateCcw size={16} />}
|
||||
>
|
||||
{t("resetProgress")}
|
||||
</LightButton>
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,12 +160,30 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
<AddCardModal
|
||||
<AddCardModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
deckId={deckId}
|
||||
onAdded={refreshCards}
|
||||
/>
|
||||
|
||||
{/* Reset Progress Confirmation Modal */}
|
||||
<Modal open={openResetModal} onClose={() => setResetModal(false)} size="sm">
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t("resetProgressTitle")}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-gray-600">{t("resetProgressConfirm")}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<LightButton onClick={() => setResetModal(false)}>
|
||||
{t("cancel")}
|
||||
</LightButton>
|
||||
<PrimaryButton onClick={handleResetDeck} loading={resetting}>
|
||||
{resetting ? t("resetting") : t("resetProgress")}
|
||||
</PrimaryButton>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,3 +162,17 @@ export type ActionOutputGetCardById = {
|
||||
message: string;
|
||||
data?: ActionOutputCardWithNote;
|
||||
};
|
||||
|
||||
export const schemaActionInputResetDeckCards = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputResetDeckCards = z.infer<typeof schemaActionInputResetDeckCards>;
|
||||
export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards);
|
||||
|
||||
export type ActionOutputResetDeckCards = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActionInputGetCardStats,
|
||||
ActionInputDeleteCard,
|
||||
ActionInputGetCardById,
|
||||
ActionInputResetDeckCards,
|
||||
ActionOutputCreateCard,
|
||||
ActionOutputAnswerCard,
|
||||
ActionOutputGetCards,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ActionOutputCard,
|
||||
ActionOutputCardWithNote,
|
||||
ActionOutputScheduledCard,
|
||||
ActionOutputResetDeckCards,
|
||||
validateActionInputCreateCard,
|
||||
validateActionInputAnswerCard,
|
||||
validateActionInputGetCardsForReview,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
validateActionInputGetCardStats,
|
||||
validateActionInputDeleteCard,
|
||||
validateActionInputGetCardById,
|
||||
validateActionInputResetDeckCards,
|
||||
} from "./card-action-dto";
|
||||
import {
|
||||
serviceCreateCard,
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
serviceDeleteCard,
|
||||
serviceGetCardByIdWithNote,
|
||||
serviceCheckCardOwnership,
|
||||
serviceResetDeckCards,
|
||||
} from "./card-service";
|
||||
import { CardQueue } from "../../../generated/prisma/enums";
|
||||
|
||||
@@ -425,3 +429,36 @@ export async function actionGetCardById(
|
||||
return { success: false, message: "An error occurred while fetching the card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionResetDeckCards(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputResetDeckCards> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputResetDeckCards(input);
|
||||
const result = await serviceResetDeckCards({
|
||||
deckId: validated.deckId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to reset deck cards", { error: e });
|
||||
return { success: false, message: "An error occurred while resetting deck cards" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,3 +102,11 @@ export type RepoOutputCardStats = {
|
||||
review: number;
|
||||
due: number;
|
||||
};
|
||||
|
||||
export interface RepoInputResetDeckCards {
|
||||
deckId: number;
|
||||
}
|
||||
|
||||
export type RepoOutputResetDeckCards = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
RepoInputGetCardsForReview,
|
||||
RepoInputGetNewCards,
|
||||
RepoInputBulkUpdateCards,
|
||||
RepoInputResetDeckCards,
|
||||
RepoOutputCard,
|
||||
RepoOutputCardWithNote,
|
||||
RepoOutputCardStats,
|
||||
RepoOutputResetDeckCards,
|
||||
} from "./card-repository-dto";
|
||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||
|
||||
@@ -307,3 +309,29 @@ export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCa
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
export async function repoResetDeckCards(
|
||||
input: RepoInputResetDeckCards,
|
||||
): Promise<RepoOutputResetDeckCards> {
|
||||
log.debug("Resetting deck cards", { deckId: input.deckId });
|
||||
|
||||
const result = await prisma.card.updateMany({
|
||||
where: { deckId: input.deckId },
|
||||
data: {
|
||||
type: CardType.NEW,
|
||||
queue: CardQueue.NEW,
|
||||
due: 0,
|
||||
ivl: 0,
|
||||
factor: 2500,
|
||||
reps: 0,
|
||||
lapses: 0,
|
||||
left: 0,
|
||||
odue: 0,
|
||||
odid: 0,
|
||||
mod: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Deck cards reset", { deckId: input.deckId, count: result.count });
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
@@ -99,6 +99,24 @@ export type ServiceOutputReviewResult = {
|
||||
scheduled: ServiceOutputScheduledCard;
|
||||
};
|
||||
|
||||
export interface ServiceInputResetDeckCards {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ServiceInputCheckDeckOwnership {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type ServiceOutputCheckDeckOwnership = boolean;
|
||||
|
||||
export type ServiceOutputResetDeckCards = {
|
||||
success: boolean;
|
||||
count: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const SM2_CONFIG = {
|
||||
LEARNING_STEPS: [1, 10],
|
||||
RELEARNING_STEPS: [10],
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
repoDeleteCard,
|
||||
repoGetCardsByNoteId,
|
||||
repoGetCardDeckOwnerId,
|
||||
repoResetDeckCards,
|
||||
} from "./card-repository";
|
||||
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
|
||||
import {
|
||||
RepoInputUpdateCard,
|
||||
RepoOutputCard,
|
||||
@@ -25,12 +27,15 @@ import {
|
||||
ServiceInputGetCardsByDeckId,
|
||||
ServiceInputGetCardStats,
|
||||
ServiceInputCheckCardOwnership,
|
||||
ServiceInputResetDeckCards,
|
||||
ServiceInputCheckDeckOwnership,
|
||||
ServiceOutputCard,
|
||||
ServiceOutputCardWithNote,
|
||||
ServiceOutputCardStats,
|
||||
ServiceOutputScheduledCard,
|
||||
ServiceOutputReviewResult,
|
||||
ServiceOutputCheckCardOwnership,
|
||||
ServiceOutputResetDeckCards,
|
||||
ReviewEase,
|
||||
SM2_CONFIG,
|
||||
} from "./card-service-dto";
|
||||
@@ -495,3 +500,27 @@ export async function serviceCheckCardOwnership(
|
||||
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
|
||||
export async function serviceCheckDeckOwnership(
|
||||
input: ServiceInputCheckDeckOwnership,
|
||||
): Promise<ServiceOutputCheckCardOwnership> {
|
||||
log.debug("Checking deck ownership", { deckId: input.deckId });
|
||||
const ownerId = await repoGetUserIdByDeckId(input.deckId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
|
||||
export async function serviceResetDeckCards(
|
||||
input: ServiceInputResetDeckCards,
|
||||
): Promise<ServiceOutputResetDeckCards> {
|
||||
log.info("Resetting deck cards", { deckId: input.deckId, userId: input.userId });
|
||||
|
||||
const isOwner = await serviceCheckDeckOwnership({ deckId: input.deckId, userId: input.userId });
|
||||
if (!isOwner) {
|
||||
return { success: false, count: 0, message: "You do not have permission to reset this deck" };
|
||||
}
|
||||
|
||||
const result = await repoResetDeckCards({ deckId: input.deckId });
|
||||
|
||||
log.info("Deck cards reset successfully", { deckId: input.deckId, count: result.count });
|
||||
return { success: true, count: result.count, message: "Deck cards reset successfully" };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function repoGenerateGuid(): string {
|
||||
|
||||
export function repoCalculateCsum(text: string): number {
|
||||
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
|
||||
return parseInt(hash.substring(0, 8), 16);
|
||||
// Use 7 hex chars to stay within INTEGER range (max 268,435,455 < 2,147,483,647)
|
||||
return parseInt(hash.substring(0, 7), 16);
|
||||
}
|
||||
|
||||
export function repoJoinFields(fields: string[]): string {
|
||||
|
||||
Reference in New Issue
Block a user