refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
223
src/app/decks/DecksClient.tsx
Normal file
223
src/app/decks/DecksClient.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
Globe,
|
||||
Lock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionCreateDeck,
|
||||
actionDeleteDeck,
|
||||
actionGetDecksByUserId,
|
||||
actionUpdateDeck,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: ActionOutputDeck;
|
||||
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
|
||||
onDeleteDeck: (deckId: number) => void;
|
||||
}
|
||||
|
||||
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("decks");
|
||||
|
||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
if (result.success) {
|
||||
onUpdateDeck(deck.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
name: newName,
|
||||
});
|
||||
if (result.success) {
|
||||
onUpdateDeck(deck.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: deck.name }));
|
||||
if (confirm === deck.name) {
|
||||
const result = await actionDeleteDeck({ deckId: deck.id });
|
||||
if (result.success) {
|
||||
onDeleteDeck(deck.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/decks/${deck.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Layers size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{deck.name}</h3>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{deck.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{deck.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("deckInfo", {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
totalCards: deck.cardCount ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={deck.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{deck.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<Pencil size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={handleDelete}
|
||||
className="hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DecksClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function DecksClient({ userId }: DecksClientProps) {
|
||||
const t = useTranslations("decks");
|
||||
const router = useRouter();
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadDecks = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetDecksByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
setDecks(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDecks();
|
||||
}, [userId]);
|
||||
|
||||
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteDeck = (deckId: number) => {
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deckId));
|
||||
};
|
||||
|
||||
const handleCreateDeck = async () => {
|
||||
const deckName = prompt(t("enterDeckName"));
|
||||
if (!deckName?.trim()) return;
|
||||
|
||||
const result = await actionCreateDeck({ name: deckName.trim() });
|
||||
if (result.success) {
|
||||
loadDecks();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateDeck}>
|
||||
<Plus size={18} />
|
||||
{t("newDeck")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : decks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Layers size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noDecksYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onUpdateDeck={handleUpdateDeck}
|
||||
onDeleteDeck={handleDeleteDeck}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
154
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
154
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { actionCreateNote } from "@/modules/note/note-action";
|
||||
import { actionCreateCard } from "@/modules/card/card-action";
|
||||
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AddCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
deckId: number;
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
export function AddCardModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
deckId,
|
||||
onAdded,
|
||||
}: AddCardModalProps) {
|
||||
const t = useTranslations("deck_id");
|
||||
const wordRef = useRef<HTMLInputElement>(null);
|
||||
const definitionRef = useRef<HTMLInputElement>(null);
|
||||
const ipaRef = useRef<HTMLInputElement>(null);
|
||||
const exampleRef = useRef<HTMLInputElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = async () => {
|
||||
const word = wordRef.current?.value?.trim();
|
||||
const definition = definitionRef.current?.value?.trim();
|
||||
|
||||
if (!word || !definition) {
|
||||
toast.error(t("wordAndDefinitionRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
let noteTypesResult = await actionGetNoteTypesByUserId();
|
||||
|
||||
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
||||
const createResult = await actionCreateDefaultBasicNoteType();
|
||||
if (!createResult.success || !createResult.data) {
|
||||
throw new Error(createResult.message || "Failed to create note type");
|
||||
}
|
||||
noteTypesResult = await actionGetNoteTypesByUserId();
|
||||
}
|
||||
|
||||
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
||||
throw new Error("No note type available");
|
||||
}
|
||||
|
||||
const noteTypeId = noteTypesResult.data[0].id;
|
||||
|
||||
const fields = [
|
||||
word,
|
||||
definition,
|
||||
ipaRef.current?.value?.trim() || "",
|
||||
exampleRef.current?.value?.trim() || "",
|
||||
];
|
||||
|
||||
const noteResult = await actionCreateNote({
|
||||
noteTypeId,
|
||||
fields,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
if (!noteResult.success || !noteResult.data) {
|
||||
throw new Error(noteResult.message || "Failed to create note");
|
||||
}
|
||||
|
||||
const cardResult = await actionCreateCard({
|
||||
noteId: BigInt(noteResult.data.id),
|
||||
deckId,
|
||||
});
|
||||
|
||||
if (!cardResult.success) {
|
||||
throw new Error(cardResult.message || "Failed to create card");
|
||||
}
|
||||
|
||||
if (wordRef.current) wordRef.current.value = "";
|
||||
if (definitionRef.current) definitionRef.current.value = "";
|
||||
if (ipaRef.current) ipaRef.current.value = "";
|
||||
if (exampleRef.current) exampleRef.current.value = "";
|
||||
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||
<div className="flex">
|
||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||
{t("addNewCard")}
|
||||
</h2>
|
||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("word")} *
|
||||
</label>
|
||||
<Input ref={wordRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("definition")} *
|
||||
</label>
|
||||
<Input ref={definitionRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("ipa")}
|
||||
</label>
|
||||
<Input ref={ipaRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("example")}
|
||||
</label>
|
||||
<Input ref={exampleRef} className="w-full"></Input>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<LightButton onClick={handleAdd} disabled={isSubmitting}>
|
||||
{isSubmitting ? t("adding") : t("add")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/app/decks/[deck_id]/CardItem.tsx
Normal file
84
src/app/decks/[deck_id]/CardItem.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { UpdateCardModal } from "./UpdateCardModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CardItemProps {
|
||||
card: ActionOutputCardWithNote;
|
||||
isReadOnly: boolean;
|
||||
onDel: () => void;
|
||||
refreshCards: () => void;
|
||||
}
|
||||
|
||||
export function CardItem({
|
||||
card,
|
||||
isReadOnly,
|
||||
onDel,
|
||||
refreshCards,
|
||||
}: CardItemProps) {
|
||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
const fields = card.note.flds.split('\x1f');
|
||||
const field1 = fields[0] || "";
|
||||
const field2 = fields[1] || "";
|
||||
|
||||
return (
|
||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{t("card")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<CircleButton
|
||||
onClick={() => setOpenUpdateModal(true)}
|
||||
title={t("edit")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={onDel}
|
||||
title={t("delete")}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||
<div>
|
||||
{field1.length > 30
|
||||
? field1.substring(0, 30) + "..."
|
||||
: field1}
|
||||
</div>
|
||||
<div>
|
||||
{field2.length > 30
|
||||
? field2.substring(0, 30) + "..."
|
||||
: field2}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateCardModal
|
||||
isOpen={openUpdateModal}
|
||||
onClose={() => setOpenUpdateModal(false)}
|
||||
card={card}
|
||||
onUpdated={() => {
|
||||
setOpenUpdateModal(false);
|
||||
refreshCards();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/app/decks/[deck_id]/InDeck.tsx
Normal file
142
src/app/decks/[deck_id]/InDeck.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } 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 { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
|
||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCards = async () => {
|
||||
setLoading(true);
|
||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to load cards");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
fetchCards();
|
||||
}, [deckId]);
|
||||
|
||||
const refreshCards = async () => {
|
||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to refresh cards");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mb-6">
|
||||
<LinkButton
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</LinkButton>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{t("cards")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: cards.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?deck_id=${deckId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{cards
|
||||
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
|
||||
.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
isReadOnly={isReadOnly}
|
||||
onDel={() => {
|
||||
actionDeleteCard({ cardId: BigInt(card.id) })
|
||||
.then(result => {
|
||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||
}).then(refreshCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
}}
|
||||
refreshCards={refreshCards}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
<AddCardModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
deckId={deckId}
|
||||
onAdded={refreshCards}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
132
src/app/decks/[deck_id]/UpdateCardModal.tsx
Normal file
132
src/app/decks/[deck_id]/UpdateCardModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { actionUpdateNote } from "@/modules/note/note-action";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UpdateCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
card: ActionOutputCardWithNote;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function UpdateCardModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
card,
|
||||
onUpdated,
|
||||
}: UpdateCardModalProps) {
|
||||
const t = useTranslations("deck_id");
|
||||
const wordRef = useRef<HTMLInputElement>(null);
|
||||
const definitionRef = useRef<HTMLInputElement>(null);
|
||||
const ipaRef = useRef<HTMLInputElement>(null);
|
||||
const exampleRef = useRef<HTMLInputElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && card) {
|
||||
const fields = card.note.flds.split('\x1f');
|
||||
if (wordRef.current) wordRef.current.value = fields[0] || "";
|
||||
if (definitionRef.current) definitionRef.current.value = fields[1] || "";
|
||||
if (ipaRef.current) ipaRef.current.value = fields[2] || "";
|
||||
if (exampleRef.current) exampleRef.current.value = fields[3] || "";
|
||||
}
|
||||
}, [isOpen, card]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const word = wordRef.current?.value?.trim();
|
||||
const definition = definitionRef.current?.value?.trim();
|
||||
|
||||
if (!word || !definition) {
|
||||
toast.error(t("wordAndDefinitionRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const fields = [
|
||||
word,
|
||||
definition,
|
||||
ipaRef.current?.value?.trim() || "",
|
||||
exampleRef.current?.value?.trim() || "",
|
||||
];
|
||||
|
||||
const result = await actionUpdateNote({
|
||||
noteId: BigInt(card.note.id),
|
||||
fields,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Failed to update note");
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
onUpdated();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleUpdate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||
<div className="flex">
|
||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||
{t("updateCard")}
|
||||
</h2>
|
||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("word")} *
|
||||
</label>
|
||||
<Input ref={wordRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("definition")} *
|
||||
</label>
|
||||
<Input ref={definitionRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("ipa")}
|
||||
</label>
|
||||
<Input ref={ipaRef} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("example")}
|
||||
</label>
|
||||
<Input ref={exampleRef} className="w-full"></Input>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<LightButton onClick={handleUpdate} disabled={isSubmitting}>
|
||||
{isSubmitting ? t("updating") : t("update")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/decks/[deck_id]/page.tsx
Normal file
37
src/app/decks/[deck_id]/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { InDeck } from "./InDeck";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||
|
||||
export default async function DecksPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ deck_id: number; }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { deck_id } = await params;
|
||||
const t = await getTranslations("deck_id");
|
||||
|
||||
if (!deck_id) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const deckInfo = (await actionGetDeckById({ deckId: Number(deck_id) })).data;
|
||||
|
||||
if (!deckInfo) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === deckInfo.userId;
|
||||
const isPublic = deckInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isOwner && !isPublic) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const isReadOnly = !isOwner;
|
||||
|
||||
return <InDeck deckId={Number(deck_id)} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
16
src/app/decks/page.tsx
Normal file
16
src/app/decks/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { auth } from "@/auth";
|
||||
import { DecksClient } from "./DecksClient";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DecksPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/decks");
|
||||
}
|
||||
|
||||
return <DecksClient userId={session.user.id} />;
|
||||
}
|
||||
Reference in New Issue
Block a user