refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View File

@@ -11,15 +11,18 @@ import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client";
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
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 type { TSharedDeck } from "@/shared/anki-type";
import { toast } from "sonner";
interface DictionaryClientProps {
initialFolders: TSharedFolder[];
initialDecks: TSharedDeck[];
}
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
const t = useTranslations("dictionary");
const router = useRouter();
const searchParams = useSearchParams();
@@ -39,7 +42,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
} = useDictionaryStore();
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
const [decks, setDecks] = useState<TSharedDeck[]>(initialDecks);
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const q = searchParams.get("q") || undefined;
@@ -55,9 +60,31 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
useEffect(() => {
if (session?.user?.id) {
actionGetFoldersByUserId(session.user.id).then((result) => {
actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setFolders(result.data);
setDecks(result.data as TSharedDeck[]);
}
});
}
}, [session?.user?.id]);
useEffect(() => {
if (session?.user?.id) {
actionGetNoteTypesByUserId().then(async (result) => {
if (result.success && result.data) {
const basicNoteType = result.data.find(
(nt) => nt.name === "Basic Vocabulary"
);
if (basicNoteType) {
setDefaultNoteTypeId(basicNoteType.id);
} else if (result.data.length > 0) {
setDefaultNoteTypeId(result.data[0].id);
} else {
const createResult = await actionCreateDefaultBasicNoteType();
if (createResult.success && createResult.data) {
setDefaultNoteTypeId(createResult.data.id);
}
}
}
});
}
@@ -79,37 +106,73 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
toast.error(t("pleaseLogin"));
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
if (decks.length === 0) {
toast.error(t("pleaseCreateFolder"));
return;
}
if (!defaultNoteTypeId) {
toast.error("No note type available. Please try again.");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!searchResult?.entries?.length) return;
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
if (!deckId) {
toast.error("No deck selected");
return;
}
setIsSaving(true);
const definition = searchResult.entries
.map((e) => e.definition)
.join(" | ");
const ipa = searchResult.entries[0]?.ipa || "";
const example = searchResult.entries
.map((e) => e.example)
.filter(Boolean)
.join(" | ") || "";
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0]?.ipa,
folderId: folderId,
const noteResult = await actionCreateNote({
noteTypeId: defaultNoteTypeId,
fields: [searchResult.standardForm, definition, ipa, example],
tags: ["dictionary"],
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
if (!noteResult.success || !noteResult.data) {
toast.error(t("saveFailed"));
setIsSaving(false);
return;
}
const noteId = BigInt(noteResult.data.id);
await actionCreateCard({
noteId,
deckId,
ord: 0,
});
await actionCreateCard({
noteId,
deckId,
ord: 1,
});
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName: deckName }));
} catch (error) {
toast.error("Save failed");
console.error("Save error:", error);
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
}
};
@@ -174,8 +237,8 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
</div>
) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
<p className="text-gray-800 text-xl">{t("noResults")}</p>
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
</div>
) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg">
@@ -186,14 +249,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
{session && decks.length > 0 && (
<select
id="folder-select"
id="deck-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
@@ -201,7 +264,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
<LightButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
title={t("saveToFolder")}
loading={isSaving}
disabled={isSaving}
>
<Plus />
</LightButton>
@@ -223,7 +288,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
loading={isSearching}
>
<RefreshCw className="w-4 h-4" />
Re-lookup
{t("relookup")}
</LightButton>
</div>
</div>

View File

@@ -1,20 +1,20 @@
import { DictionaryClient } from "./DictionaryClient";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { TSharedDeck } from "@/shared/anki-type";
export default async function DictionaryPage() {
const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
let decks: TSharedDeck[] = [];
if (session?.user?.id) {
const result = await actionGetFoldersByUserId(session.user.id as string);
const result = await actionGetDecksByUserId(session.user.id as string);
if (result.success && result.data) {
folders = result.data;
decks = result.data as TSharedDeck[];
}
}
return <DictionaryClient initialFolders={folders} />;
return <DictionaryClient initialDecks={decks} />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import {
Folder as Fd,
Layers,
Heart,
Search,
ArrowUpDown,
@@ -14,35 +14,35 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import {
actionSearchPublicFolders,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-action";
import { TPublicFolder } from "@/shared/folder-type";
actionSearchPublicDecks,
actionToggleDeckFavorite,
actionCheckDeckFavorite,
} from "@/modules/deck/deck-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps {
folder: TPublicFolder;
interface PublicDeckCardProps {
deck: ActionOutputPublicDeck;
currentUserId?: string;
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
}
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
const router = useRouter();
const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
}, [deck.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -50,11 +50,11 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
const result = await actionToggleDeckFavorite({ deckId: deck.id });
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
} else {
toast.error(result.message);
}
@@ -64,13 +64,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
<div
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => {
router.push(`/explore/${folder.id}`);
router.push(`/explore/${deck.id}`);
}}
>
<div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={18} className="sm:hidden" />
<Fd size={22} className="hidden sm:block" />
<Layers size={18} className="sm:hidden" />
<Layers size={22} className="hidden sm:block" />
</div>
<CircleButton
onClick={handleToggleFavorite}
@@ -83,12 +83,12 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
</CircleButton>
</div>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{deck.name}</h3>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
{t("deckInfo", {
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
cardCount: deck.cardCount ?? 0,
})}
</p>
@@ -101,13 +101,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
};
interface ExploreClientProps {
initialPublicFolders: TPublicFolder[];
initialPublicDecks: ActionOutputPublicDeck[];
}
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
const t = useTranslations("explore");
const router = useRouter();
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
@@ -117,13 +117,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
setPublicDecks(initialPublicDecks);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
if (result.success && result.data) {
setPublicFolders(result.data);
setPublicDecks(result.data);
}
setLoading(false);
};
@@ -132,14 +132,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setSortByFavorites((prev) => !prev);
};
const sortedFolders = sortByFavorites
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicFolders;
const sortedDecks = sortByFavorites
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicDecks;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) =>
prev.map((f) =>
f.id === folderId ? { ...f, favoriteCount } : f
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicDecks((prev) =>
prev.map((d) =>
d.id === deckId ? { ...d, favoriteCount } : d
)
);
};
@@ -177,19 +177,19 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
<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>
) : sortedFolders.length === 0 ? (
) : sortedDecks.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">
<Fd size={24} className="text-gray-400" />
<Layers size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFolders")}</p>
<p className="text-sm">{t("noDecks")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{sortedFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
{sortedDecks.map((deck) => (
<PublicDeckCard
key={deck.id}
deck={deck}
currentUserId={currentUserId}
onUpdateFavorite={handleUpdateFavorite}
/>

View File

@@ -1,6 +1,6 @@
"use client";
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
import { Layers, Heart, ExternalLink, ArrowLeft } from "lucide-react";
import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
@@ -8,42 +8,42 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import Link from "next/link";
import {
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-action";
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
actionToggleDeckFavorite,
actionCheckDeckFavorite,
} from "@/modules/deck/deck-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
import { authClient } from "@/lib/auth-client";
interface ExploreDetailClientProps {
folder: ActionOutputPublicFolder;
deck: ActionOutputPublicDeck;
}
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
const router = useRouter();
const t = useTranslations("exploreDetail");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id;
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
}, [deck.id, currentUserId]);
const handleToggleFavorite = async () => {
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
const result = await actionToggleDeckFavorite({ deckId: deck.id });
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
@@ -79,15 +79,15 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={28} className="sm:w-8 sm:h-8" />
<Layers size={28} className="sm:w-8 sm:h-8" />
</div>
<div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{folder.name}
{deck.name}
</h2>
<p className="text-sm text-gray-500 mt-1">
{t("createdBy", {
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
name: deck.userName ?? deck.userUsername ?? t("unknownUser"),
})}
</p>
</div>
@@ -104,13 +104,19 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
</CircleButton>
</div>
{deck.desc && (
<p className="text-gray-600 mb-6 text-sm sm:text-base">
{deck.desc}
</p>
)}
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
{folder.totalPairs}
{deck.cardCount ?? 0}
</div>
<div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("totalPairs")}
{t("totalCards")}
</div>
</div>
<div className="text-center border-x border-gray-100">
@@ -124,7 +130,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
</div>
<div className="text-center">
<div className="text-lg sm:text-xl font-semibold text-gray-700">
{formatDate(folder.createdAt)}
{formatDate(deck.createdAt)}
</div>
<div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("createdAt")}
@@ -133,7 +139,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
</div>
<Link
href={`/folders/${folder.id}`}
href={`/decks/${deck.id}`}
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-primary-500 hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
>
<ExternalLink size={18} />

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { ExploreDetailClient } from "./ExploreDetailClient";
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
import { actionGetPublicDeckById } from "@/modules/deck/deck-action";
export default async function ExploreFolderPage({
export default async function ExploreDeckPage({
params,
}: {
params: Promise<{ id: string }>;
@@ -13,11 +13,11 @@ export default async function ExploreFolderPage({
redirect("/explore");
}
const result = await actionGetPublicFolderById(Number(id));
const result = await actionGetPublicDeckById({ deckId: Number(id) });
if (!result.success || !result.data) {
redirect("/explore");
}
return <ExploreDetailClient folder={result.data} />;
return <ExploreDetailClient deck={result.data} />;
}

View File

@@ -1,9 +1,9 @@
import { ExploreClient } from "./ExploreClient";
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
import { actionGetPublicDecks } from "@/modules/deck/deck-action";
export default async function ExplorePage() {
const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
const publicDecksResult = await actionGetPublicDecks();
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : [];
return <ExploreClient initialPublicFolders={publicFolders} />;
return <ExploreClient initialPublicDecks={publicDecks} />;
}

View File

@@ -2,33 +2,22 @@
import {
ChevronRight,
Folder as Fd,
Layers as DeckIcon,
Heart,
} from "lucide-react";
import { useEffect, useState } from "react";
import { 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 { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
type UserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
interface FavoriteCardProps {
favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
favorite: ActionOutputUserFavoriteDeck;
onRemoveFavorite: (deckId: number) => void;
}
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
@@ -41,9 +30,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
if (isRemoving) return;
setIsRemoving(true);
const result = await actionToggleFavorite(favorite.folderId);
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
if (result.success) {
onRemoveFavorite(favorite.folderId);
onRemoveFavorite(favorite.id);
} else {
toast.error(result.message);
}
@@ -54,20 +43,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
<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(`/explore/${favorite.folderId}`);
router.push(`/explore/${favorite.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
<DeckIcon size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.folderTotalPairs,
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
totalPairs: favorite.cardCount ?? 0,
})}
</p>
</div>
@@ -86,29 +75,25 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
};
interface FavoritesClientProps {
userId: string;
initialFavorites: ActionOutputUserFavoriteDeck[];
}
export function FavoritesClient({ userId }: FavoritesClientProps) {
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
const [loading, setLoading] = useState(false);
const loadFavorites = async () => {
setLoading(true);
const result = await actionGetUserFavorites();
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
setFavorites(result.data);
}
setLoading(false);
};
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
const handleRemoveFavorite = (deckId: number) => {
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
};
return (

View File

@@ -2,6 +2,8 @@ import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { FavoritesClient } from "./FavoritesClient";
import { actionGetUserFavoriteDecks } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
export default async function FavoritesPage() {
const session = await auth.api.getSession({ headers: await headers() });
@@ -10,5 +12,11 @@ export default async function FavoritesPage() {
redirect("/login?redirect=/favorites");
}
return <FavoritesClient userId={session.user.id} />;
let favorites: ActionOutputUserFavoriteDeck[] = [];
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
favorites = result.data;
}
return <FavoritesClient initialFavorites={favorites} />;
}

View File

@@ -0,0 +1,114 @@
"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,93 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const t = useTranslations("memorize.folder_selector");
const router = useRouter();
return (
<PageLayout>
{folders.length === 0 ? (
// 空状态 - 显示提示和跳转按钮
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noFolders")}
</h1>
<Link href="/folders">
<PrimaryButton className="px-6 py-2">
Go to Folders
</PrimaryButton>
</Link>
</div>
) : (
<>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.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">
<Fd className="text-gray-600" size="md" />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</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 { FolderSelector };

View File

@@ -1,192 +1,275 @@
"use client";
import { useState } from "react";
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useState, useEffect, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { TSharedPair } from "@/shared/folder-type";
import { Layers, Check, Clock } 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";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
textPairs: TSharedPair[];
deckId: number;
deckName: string;
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const t = useTranslations("memorize.memorize");
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [disorder, setDisorder] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
type ReviewEase = 1 | 2 | 3 | 4;
if (textPairs.length === 0) {
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(() => {
loadCards();
}, [deckId]);
const loadCards = () => {
setIsLoading(true);
setError(null);
startTransition(async () => {
const result = await actionGetCardsForReview({ deckId, limit: 50 });
if (result.success && result.data) {
setCards(result.data);
setCurrentIndex(0);
setShowAnswer(false);
setLastScheduled(null);
} else {
setError(result.message);
}
setIsLoading(false);
});
};
const getCurrentCard = (): ActionOutputCardWithNote | null => {
return cards[currentIndex] ?? null;
};
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
return card.note.flds.split('\x1f');
};
const handleShowAnswer = () => {
setShowAnswer(true);
};
const handleAnswer = (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);
}
});
};
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) });
};
if (isLoading) {
return (
<PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
<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>
);
}
const rng = new SeededRandom(textPairs[0].folderId);
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
textPairs.sort((a, b) => a.id - b.id);
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
const handleIndexClick = () => {
const newIndex = prompt("Input a index number.")?.trim();
if (
newIndex &&
isNonNegativeInteger(newIndex) &&
parseInt(newIndex) <= textPairs.length &&
parseInt(newIndex) > 0
) {
setIndex(parseInt(newIndex) - 1);
}
};
const handleNext = async () => {
if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex);
if (dictation) {
const textPair = getTextPairs()[newIndex];
const language = textPair[reverse ? "language2" : "language1"];
const text = textPair[reverse ? "text2" : "text1"];
// 映射语言到 TTS 支持的格式
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"russian": "Russian",
};
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
getTTSUrl(text, ttsLanguage).then((url) => {
load(url);
play();
});
}
}
setShow(show === "question" ? "answer" : "question");
};
const handlePrevious = () => {
setIndex(
(index - 1 + getTextPairs().length) % getTextPairs().length,
);
setShow("question");
};
const toggleReverse = () => setReverse(!reverse);
const toggleDictation = () => setDictation(!dictation);
const toggleDisorder = () => setDisorder(!disorder);
const createText = (text: string) => {
if (error) {
return (
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
{text}
</div>
<PageLayout>
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</div>
</PageLayout>
);
};
}
const [text1, text2] = reverse
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
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("/memorize")} 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] ?? "";
return (
<PageLayout>
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<LinkButton onClick={handleIndexClick} className="text-sm">
{index + 1} / {getTextPairs().length}
</LinkButton>
<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="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
</div>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</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>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
onClick={handleNext}
className="px-4 py-2 rounded-full text-sm"
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
onClick={handlePrevious}
className="px-4 py-2 rounded-full text-sm"
>
{t("previous")}
</LightButton>
<CircleToggleButton
selected={reverse}
onClick={toggleReverse}
>
{t("reverse")}
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
onClick={toggleDictation}
>
{t("dictation")}
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
onClick={toggleDisorder}
>
{t("disorder")}
</CircleToggleButton>
{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")}
</LightButton>
) : (
<div className="flex flex-wrap justify-center gap-3">
<button
onClick={() => handleAnswer(1)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
>
<span className="font-medium">{t("again")}</span>
<span className="text-xs opacity-75">&lt;1{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(2)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
>
<span className="font-medium">{t("hard")}</span>
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(3)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
>
<span className="font-medium">{t("good")}</span>
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(4)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
>
<span className="font-medium">{t("easy")}</span>
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
</button>
</div>
)}
</div>
</PageLayout>
);

View File

@@ -1,37 +1,57 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { FolderSelector } from "./FolderSelector";
import { DeckSelector } from "./DeckSelector";
import { Memorize } from "./Memorize";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionGetCardStats } from "@/modules/card/card-action";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string; }>;
searchParams: Promise<{ deck_id?: string; }>;
}) {
const tParam = (await searchParams).folder_id;
const deckIdParam = (await searchParams).deck_id;
const t = await getTranslations("memorize.page");
const folder_id = tParam
? isNonNegativeInteger(tParam)
? parseInt(tParam)
const deckId = deckIdParam
? isNonNegativeInteger(deckIdParam)
? parseInt(deckIdParam)
: null
: null;
if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
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 (
<FolderSelector
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
<DeckSelector
decks={decks}
deckStats={deckStats}
/>
);
}
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
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

@@ -10,32 +10,42 @@ import { Card } from "@/design-system/base/card";
import { toast } from "sonner";
import { Upload, FileImage, Loader2 } from "lucide-react";
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
import { TSharedFolder } from "@/shared/folder-type";
import { OCROutput } from "@/lib/bigmodel/ocr/types";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
interface OCRClientProps {
initialFolders: TSharedFolder[];
interface ActionOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}
export function OCRClient({ initialFolders }: OCRClientProps) {
interface OCRClientProps {
initialDecks: ActionOutputDeck[];
}
export function OCRClient({ initialDecks }: OCRClientProps) {
const t = useTranslations("ocr");
const fileInputRef = useRef<HTMLInputElement>(null);
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(
initialFolders.length > 0 ? initialFolders[0].id : null
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
initialDecks.length > 0 ? initialDecks[0].id : null
);
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
const [ocrResult, setOcrResult] = useState<OCROutput | null>(null);
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
const handleFileChange = useCallback((file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error(t("processingFailed"));
toast.error(t("invalidFileType"));
return;
}
@@ -74,8 +84,8 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
return;
}
if (!selectedFolderId) {
toast.error(t("noFolder"));
if (!selectedDeckId) {
toast.error(t("noDeck"));
return;
}
@@ -87,16 +97,17 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
const result = await actionProcessOCR({
imageBase64: base64,
folderId: selectedFolderId,
deckId: selectedDeckId,
sourceLanguage: sourceLanguage || undefined,
targetLanguage: targetLanguage || undefined,
});
if (result.success) {
const folderName = initialFolders.find(f => f.id === selectedFolderId)?.name || "";
toast.success(t("saved", { count: result.data?.pairsCreated ?? 0, folder: folderName }));
if (result.success && result.data) {
setOcrResult(result);
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
} else {
toast.error(result.message || t("processingFailed"));
toast.error(result.message || t("ocrFailed"));
}
} catch {
toast.error(t("processingFailed"));
@@ -105,6 +116,20 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
}
};
const handleSave = async () => {
if (!ocrResult || !selectedDeckId) {
toast.error(t("noResultsToSave"));
return;
}
try {
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
} catch (error) {
toast.error(t("saveFailed"));
}
};
const clearImage = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
@@ -118,136 +143,144 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
};
return (
<PageLayout>
<div className="text-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t("title")}</h1>
<p className="text-gray-600">{t("description")}</p>
<PageLayout variant="centered-card">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<div className="space-y-6">
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800 flex items-center gap-2">
<Upload className="w-5 h-5" />
{t("uploadImage")}
</div>
<Card variant="bordered" padding="lg">
<div className="space-y-6">
{/* Upload Section */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("uploadSection")}
</h2>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
previewUrl
? "border-primary-300 bg-primary-50"
: "border-gray-300 hover:border-primary-400 hover:bg-gray-50"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
{previewUrl ? (
<div className="space-y-3">
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="max-h-64 mx-auto rounded-lg shadow-md"
className="mx-auto max-w-full h-64 object-contain rounded-lg"
/>
<div className="flex justify-center gap-2">
<LightButton
type="button"
onClick={(e) => {
e.stopPropagation();
clearImage();
}}
>
{t("uploadImage")}
</LightButton>
</div>
<p className="text-gray-600">{t("changeImage")}</p>
</div>
) : (
<div className="space-y-3 text-gray-500">
<FileImage className="w-12 h-12 mx-auto text-gray-400" />
<p>{t("dragDropHint")}</p>
<p className="text-sm">{t("supportedFormats")}</p>
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="text-gray-600">{t("dropOrClick")}</p>
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
/>
</div>
{/* Deck Selection */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("deckSelection")}
</h2>
<Select
value={selectedDeckId?.toString() || ""}
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
className="w-full"
>
<option value="">{t("selectDeck")}</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
{/* Language Hints */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("languageHints")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="text"
placeholder={t("sourceLanguagePlaceholder")}
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
className="w-full"
/>
<Input
type="text"
placeholder={t("targetLanguagePlaceholder")}
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
className="w-full"
/>
</div>
</div>
</Card>
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800">{t("selectFolder")}</div>
{initialFolders.length > 0 ? (
<Select
value={selectedFolderId?.toString() || ""}
onChange={(e) => setSelectedFolderId(Number(e.target.value))}
className="w-full"
>
{initialFolders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</Select>
) : (
<p className="text-gray-500 text-sm">{t("noFolders")}</p>
)}
{/* Process Button */}
<div className="flex justify-center">
<PrimaryButton
onClick={handleProcess}
disabled={!selectedFile || !selectedDeckId || isProcessing}
loading={isProcessing}
className="px-8 py-3 text-lg"
>
{t("processButton")}
</PrimaryButton>
</div>
</Card>
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800">{t("languageHints")}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-600 block mb-1">
{t("sourceLanguageHint")}
</label>
<Input
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="English"
/>
{/* Results Preview */}
{ocrResult && ocrResult.data && (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("resultsPreview")}
</h2>
<div className="bg-gray-50 rounded-lg p-4">
<div className="space-y-3">
<div className="text-center py-4">
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
</div>
</div>
{ocrResult.data.sourceLanguage && (
<div className="mt-4 text-sm text-gray-500">
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
</div>
)}
{ocrResult.data.targetLanguage && (
<div className="mt-1 text-sm text-gray-500">
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
</div>
)}
</div>
<div>
<label className="text-sm text-gray-600 block mb-1">
{t("targetLanguageHint")}
</label>
<Input
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
placeholder="Chinese"
/>
<div className="mt-4 flex justify-center">
<LightButton
onClick={handleSave}
disabled={!selectedDeckId}
className="px-6 py-2"
>
{t("saveButton")}
</LightButton>
</div>
</div>
</div>
</Card>
<div className="flex justify-center">
<PrimaryButton
onClick={handleProcess}
disabled={isProcessing || !selectedFile || !selectedFolderId}
size="lg"
className="px-8"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
{t("processing")}
</>
) : (
t("process")
)}
</PrimaryButton>
)}
</div>
</div>
</Card>
</PageLayout>
);
}

View File

@@ -1,20 +1,20 @@
import { OCRClient } from "./OCRClient";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
export default async function OCRPage() {
const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
let decks: ActionOutputDeck[] = [];
if (session?.user?.id) {
const result = await actionGetFoldersByUserId(session.user.id as string);
const result = await actionGetDecksByUserId(session.user.id as string);
if (result.success && result.data) {
folders = result.data;
decks = result.data;
}
}
return <OCRClient initialFolders={folders} />;
return <OCRClient initialDecks={decks} />;
}