refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
114
src/app/(features)/memorize/DeckSelector.tsx
Normal file
114
src/app/(features)/memorize/DeckSelector.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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"><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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user