diff --git a/src/app/(features)/memorize/FolderSelector.tsx b/src/app/(features)/memorize/FolderSelector.tsx index e519249..f95985d 100644 --- a/src/app/(features)/memorize/FolderSelector.tsx +++ b/src/app/(features)/memorize/FolderSelector.tsx @@ -1,8 +1,6 @@ "use client"; -import Container from "@/components/ui/Container"; import { useRouter } from "next/navigation"; -import { Center } from "@/components/common/Center"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { Folder } from "../../../../generated/prisma/browser"; @@ -15,49 +13,77 @@ interface FolderSelectorProps { const FolderSelector: React.FC = ({ folders }) => { const t = useTranslations("memorize.folder_selector"); const router = useRouter(); + return ( -
- - {(folders.length === 0 && ( -

- {t("noFolders")} - - folders - -

- )) || ( - <> -

- {t("selectFolder")} -

-
- {folders - .toSorted((a, b) => a.id - b.id) - .map((folder) => ( -
- router.push(`/memorize?folder_id=${folder.id}`) - } - className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50" - > - -
- - {t("folderInfo", { - id: folder.id, - name: folder.name, - count: folder.total, - })} - -
-
- ))} +
+
+
+ {folders.length === 0 ? ( +
+

+ {t("noFolders")} +

+ + Go to Folders +
- - )} - -
+ ) : ( + <> +

+ {t("selectFolder")} +

+
+ {folders + .toSorted((a, b) => a.id - b.id) + .map((folder) => ( +
+ 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" + > +
+ +
+
+
+ {folder.name} +
+
+ {t("folderInfo", { + id: folder.id, + name: folder.name, + count: folder.total, + })} +
+
+
+ + + +
+
+ ))} +
+ + )} + + + ); }; diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx index 3d112fc..4f13dbc 100644 --- a/src/app/(features)/memorize/Memorize.tsx +++ b/src/app/(features)/memorize/Memorize.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import LightButton from "@/components/ui/buttons/LightButton"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { getTTSAudioUrl } from "@/lib/browser/tts"; import { VOICES } from "@/config/locales"; @@ -28,7 +27,13 @@ const Memorize: React.FC = ({ textPairs }) => { const { load, play } = useAudioPlayer(); if (textPairs.length === 0) { - return

{t("noTextPairs")}

; + return ( +
+
+

{t("noTextPairs")}

+
+
+ ); } const rng = new SeededRandom(textPairs[0].folderId); @@ -38,135 +43,160 @@ const Memorize: React.FC = ({ textPairs }) => { 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) + getTTSAudioUrl( + getTextPairs()[newIndex][reverse ? "text2" : "text1"], + VOICES.find( + (v) => + v.locale === + getTextPairs()[newIndex][ + reverse ? "locale2" : "locale1" + ], + )!.short_name, + ).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) => { + return ( +
+ {text} +
+ ); + }; + + const [text1, text2] = reverse + ? [getTextPairs()[index].text2, getTextPairs()[index].text1] + : [getTextPairs()[index].text1, getTextPairs()[index].text2]; + return ( - <> - {(getTextPairs().length > 0 && ( - <> -
-
{ - const newIndex = prompt("Input a index number.")?.trim(); - if ( - newIndex && - isNonNegativeInteger(newIndex) && - parseInt(newIndex) <= textPairs.length && - parseInt(newIndex) > 0 - ) { - setIndex(parseInt(newIndex) - 1); - } - }} +
+
+
+ {/* 进度指示器 */} +
+
-
- {(() => { - const createText = (text: string) => { + {index + 1} / {getTextPairs().length} + +
+ + {/* 文本显示区域 */} +
+ {(() => { + if (dictation) { + if (show === "question") { return ( -
- {text} +
+
?
); - }; - - const [text1, text2] = reverse - ? [getTextPairs()[index].text2, getTextPairs()[index].text1] - : [getTextPairs()[index].text1, getTextPairs()[index].text2]; - - if (dictation) { - // dictation - if (show === "question") { - return createText(""); - } else { - return ( - <> - {createText(text1)} - {createText(text2)} - - ); - } } else { - // non-dictation - if (show === "question") { - return createText(text1); - } else { - return ( - <> - {createText(text1)} - {createText(text2)} - - ); - } + return ( +
+ {createText(text1)} +
+ {createText(text2)} +
+ ); } - })()} -
+ } else { + if (show === "question") { + return createText(text1); + } else { + return ( +
+ {createText(text1)} +
+ {createText(text2)} +
+ ); + } + } + })()}
+ + {/* 底部按钮 */}
- { - if (show === "answer") { - const newIndex = (index + 1) % getTextPairs().length; - setIndex(newIndex); - if (dictation) - getTTSAudioUrl( - getTextPairs()[newIndex][reverse ? "text2" : "text1"], - VOICES.find( - (v) => - v.locale === - getTextPairs()[newIndex][ - reverse ? "locale2" : "locale1" - ], - )!.short_name, - ).then((url) => { - load(url); - play(); - }); - } - setShow(show === "question" ? "answer" : "question"); - }} + + + + +
- - )) ||

{t("noTextPairs")}

} - +
+
+
); }; diff --git a/src/app/folders/FoldersClient.tsx b/src/app/folders/FoldersClient.tsx index fba1b47..d5cf973 100644 --- a/src/app/folders/FoldersClient.tsx +++ b/src/app/folders/FoldersClient.tsx @@ -8,7 +8,6 @@ import { Trash2, } from "lucide-react"; import { useEffect, useState } from "react"; -import { Center } from "@/components/common/Center"; import { useRouter } from "next/navigation"; import { Folder } from "../../../generated/prisma/browser"; import { @@ -19,6 +18,9 @@ import { } from "@/lib/server/services/folderService"; 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"; interface FolderProps { folder: Folder & { total: number }; @@ -27,8 +29,8 @@ interface FolderProps { const FolderCard = ({ folder, refresh }: FolderProps) => { const router = useRouter(); - const t = useTranslations("folders"); + return (
{ }} >
-
- +
+
-

+

{folder.name}

+

{t("folderInfo", { id: folder.id, name: folder.name, totalPairs: folder.total, })} - +

- -
#{folder.id}
-
+
@@ -113,40 +114,38 @@ export default function FoldersClient({ userId }: { userId: string }) { console.error(error); } }; + return ( -
-
-
-

{t("title")}

-

{t("subtitle")}

-
+ + - + -
+
+ {folders.length === 0 ? (
-
+

{t("noFoldersYet")}

@@ -164,8 +163,8 @@ export default function FoldersClient({ userId }: { userId: string }) { ))}
)} -
+
-
+ ); } diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/folders/[folder_id]/InFolder.tsx index c13d257..fa6d010 100644 --- a/src/app/folders/[folder_id]/InFolder.tsx +++ b/src/app/folders/[folder_id]/InFolder.tsx @@ -1,10 +1,8 @@ "use client"; import { ArrowLeft, Plus } from "lucide-react"; -import { Center } from "@/components/common/Center"; import { useEffect, useState } from "react"; import { redirect, useRouter } from "next/navigation"; -import Container from "@/components/ui/Container"; import { createPair, deletePairById, @@ -12,8 +10,11 @@ import { } from "@/lib/server/services/pairService"; import AddTextPairModal from "./AddTextPairModal"; import TextPairCard from "./TextPairCard"; -import LightButton from "@/components/ui/buttons/LightButton"; import { useTranslations } from "next-intl"; +import PageLayout from "@/components/ui/PageLayout"; +import GreenButton from "@/components/ui/buttons/GreenButton"; +import IconButton from "@/components/ui/buttons/IconButton"; +import CardList from "@/components/ui/CardList"; export interface TextPair { id: number; @@ -55,79 +56,73 @@ export default function InFolder({ folderId }: { folderId: number }) { }; return ( -
- -
- + +
+ -
-
-

- {t("textPairs")} -

-

- {t("itemsCount", { count: textPairs.length })} -

-
+
+
+

+ {t("textPairs")} +

+

+ {t("itemsCount", { count: textPairs.length })} +

+
-
- { - redirect(`/memorize?folder_id=${folderId}`); - }} - > - {t("memorize")} - - -
+
+ { + redirect(`/memorize?folder_id=${folderId}`); + }} + > + {t("memorize")} + + { + setAddModal(true); + }} + icon={} + />
+
+ + + {loading ? ( +
+
+

{t("loadingTextPairs")}

+
+ ) : textPairs.length === 0 ? ( +
+

{t("noTextPairs")}

+
+ ) : ( +
+ {textPairs + .toSorted((a, b) => a.id - b.id) + .map((textPair) => ( + { + deletePairById(textPair.id); + refreshTextPairs(); + }} + refreshTextPairs={refreshTextPairs} + /> + ))} +
+ )} +
-
- {loading ? ( -
-
-

{t("loadingTextPairs")}

-
- ) : textPairs.length === 0 ? ( -
-

{t("noTextPairs")}

-
- ) : ( -
- {textPairs - .toSorted((a, b) => a.id - b.id) - .map((textPair) => ( - { - deletePairById(textPair.id); - refreshTextPairs(); - }} - refreshTextPairs={refreshTextPairs} - /> - ))} -
- )} -
- setAddModal(false)} @@ -151,6 +146,6 @@ export default function InFolder({ folderId }: { folderId: number }) { refreshTextPairs(); }} /> -
+ ); } diff --git a/src/components/ui/CardList.tsx b/src/components/ui/CardList.tsx new file mode 100644 index 0000000..e177436 --- /dev/null +++ b/src/components/ui/CardList.tsx @@ -0,0 +1,12 @@ +interface CardListProps { + children: React.ReactNode; + className?: string; +} + +export default function CardList({ children, className = "" }: CardListProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..47abe5f --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,17 @@ +interface PageHeaderProps { + title: string; + subtitle?: string; +} + +export default function PageHeader({ title, subtitle }: PageHeaderProps) { + return ( +
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/src/components/ui/PageLayout.tsx b/src/components/ui/PageLayout.tsx new file mode 100644 index 0000000..d9735c2 --- /dev/null +++ b/src/components/ui/PageLayout.tsx @@ -0,0 +1,16 @@ +interface PageLayoutProps { + children: React.ReactNode; + className?: string; +} + +export default function PageLayout({ children, className = "" }: PageLayoutProps) { + return ( +
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/components/ui/buttons/GrayButton.tsx b/src/components/ui/buttons/GrayButton.tsx new file mode 100644 index 0000000..1af16dc --- /dev/null +++ b/src/components/ui/buttons/GrayButton.tsx @@ -0,0 +1,34 @@ +import PlainButton, { ButtonType } from "./PlainButton"; + +interface GrayButtonProps { + onClick?: () => void; + children: React.ReactNode; + className?: string; + selected?: boolean; + type?: ButtonType; + disabled?: boolean; +} + +export default function GrayButton({ + onClick, + children, + className = "", + selected = false, + type = "button", + disabled = false, +}: GrayButtonProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/buttons/GreenButton.tsx b/src/components/ui/buttons/GreenButton.tsx new file mode 100644 index 0000000..48e327f --- /dev/null +++ b/src/components/ui/buttons/GreenButton.tsx @@ -0,0 +1,28 @@ +import PlainButton, { ButtonType } from "./PlainButton"; + +interface GreenButtonProps { + onClick?: () => void; + children: React.ReactNode; + className?: string; + type?: ButtonType; + disabled?: boolean; +} + +export default function GreenButton({ + onClick, + children, + className = "", + type = "button", + disabled = false, +}: GreenButtonProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/buttons/IconButton.tsx b/src/components/ui/buttons/IconButton.tsx new file mode 100644 index 0000000..26d809c --- /dev/null +++ b/src/components/ui/buttons/IconButton.tsx @@ -0,0 +1,28 @@ +import PlainButton, { ButtonType } from "./PlainButton"; + +interface IconButtonProps { + onClick?: () => void; + icon: React.ReactNode; + className?: string; + type?: ButtonType; + disabled?: boolean; +} + +export default function IconButton({ + onClick, + icon, + className = "", + type = "button", + disabled = false, +}: IconButtonProps) { + return ( + + {icon} + + ); +}