From b74e985770241fded88dd63b6f0a3950363faa02 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Thu, 30 Oct 2025 13:48:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=83=8C=E5=8D=95=E8=AF=8D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 2 +- public/changelog.txt | 1 + src/app/memorize/Choose.tsx | 18 +++--- src/app/memorize/Edit.tsx | 37 ++++++------ src/app/memorize/Main.tsx | 52 ++++++++++++---- src/app/memorize/Start.tsx | 83 ++++++++++++++++++++------ src/app/memorize/page.tsx | 41 +++++++------ src/app/page.tsx | 8 ++- src/components/NavbarCenterWrapper.tsx | 18 ++++++ src/components/NavbarWrapper.tsx | 14 +++++ src/components/Window.tsx | 12 ---- src/components/cards/ACard.tsx | 2 +- src/hooks/useFileUpload.ts | 17 ++++++ src/interfaces.ts | 16 +++++ 14 files changed, 231 insertions(+), 90 deletions(-) create mode 100644 src/components/NavbarCenterWrapper.tsx create mode 100644 src/components/NavbarWrapper.tsx delete mode 100644 src/components/Window.tsx create mode 100644 src/hooks/useFileUpload.ts diff --git a/next.config.ts b/next.config.ts index 5dc6561..0fe4db7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ output: "standalone", - allowedDevOrigins: ["192.168.3.65"], + allowedDevOrigins: ["192.168.3.65", "192.168.3.66"], }; export default nextConfig; diff --git a/public/changelog.txt b/public/changelog.txt index 6ac4123..668037c 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,3 +1,4 @@ +2025.10.30 添加背单词功能 2025.10.12 添加朗读器本地保存功能 2025.10.09 新增记忆字母表功能 2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项 diff --git a/src/app/memorize/Choose.tsx b/src/app/memorize/Choose.tsx index d02b58f..a0cacf7 100644 --- a/src/app/memorize/Choose.tsx +++ b/src/app/memorize/Choose.tsx @@ -1,10 +1,10 @@ import LightButton from "@/components/buttons/LightButton"; import ACard from "@/components/cards/ACard"; import BCard from "@/components/cards/BCard"; -import Window from "@/components/Window"; import { LOCALES } from "@/config/locales"; import { Dispatch, SetStateAction, useState } from "react"; -import { WordData } from "./page"; +import { WordData } from "@/interfaces"; +import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; interface Props { setEditPage: Dispatch>; @@ -37,13 +37,13 @@ export default function Choose({ }; return ( - + -
+
{LOCALES.map((locale, index) => ( setChosenLocale(locale)} > @@ -53,13 +53,11 @@ export default function Choose({
- choose - setEditPage("edit")}> - Back - + Choose + setEditPage("edit")}>Back
- + ); } diff --git a/src/app/memorize/Edit.tsx b/src/app/memorize/Edit.tsx index f03c744..926f3d3 100644 --- a/src/app/memorize/Edit.tsx +++ b/src/app/memorize/Edit.tsx @@ -1,11 +1,11 @@ import LightButton from "@/components/buttons/LightButton"; import ACard from "@/components/cards/ACard"; import BCard from "@/components/cards/BCard"; -import Window from "@/components/Window"; -import { ChangeEvent, Dispatch, SetStateAction, useState } from "react"; +import { ChangeEvent, Dispatch, SetStateAction, useRef, useState } from "react"; import DarkButton from "@/components/buttons/DarkButton"; -import { WordData } from "./page"; +import { WordData } from "@/interfaces"; import Choose from "./Choose"; +import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; interface Props { setPage: Dispatch>; @@ -14,6 +14,7 @@ interface Props { } export default function Edit({ setPage, wordData, setWordData }: Props) { + const textareaRef = useRef(null); const [localeKey, setLocaleKey] = useState<0 | 1>(0); const [editPage, setEditPage] = useState<"choose" | "edit">("edit"); const convertIntoWordData = (text: string) => { @@ -33,44 +34,46 @@ export default function Edit({ setPage, wordData, setWordData }: Props) { locales: [...wordData.locales], wordPairs: t2, }; - setWordData(new_data); + return new_data; }; - const convertFromWordData = () => { + const convertFromWordData = (wdata: WordData) => { let result = ""; - for (const pair of wordData.wordPairs) { + for (const pair of wdata.wordPairs) { result += `${pair[0]}, ${pair[1]}\n`; } return result; }; - let input = convertFromWordData(); + let input = convertFromWordData(wordData); const handleSave = () => { - convertIntoWordData(input); + const newWordData = convertIntoWordData(input); + setWordData(newWordData); + if (textareaRef.current) + textareaRef.current.value = convertFromWordData(newWordData); }; const handleChange = (e: ChangeEvent) => { input = e.target.value; }; if (editPage === "edit") return ( - +
- setPage("main")}> - Back - - Save Text + setPage("main")}>Back + Save Pairs { setLocaleKey(0); setEditPage("choose"); }} > - Choose Locale 1 + Locale 1 { @@ -78,12 +81,12 @@ export default function Edit({ setPage, wordData, setWordData }: Props) { setEditPage("choose"); }} > - Choose Locale 2 + Locale 2
-
+ ); if (editPage === "choose") return ( diff --git a/src/app/memorize/Main.tsx b/src/app/memorize/Main.tsx index bc8a528..299cfa9 100644 --- a/src/app/memorize/Main.tsx +++ b/src/app/memorize/Main.tsx @@ -1,18 +1,47 @@ import LightButton from "@/components/buttons/LightButton"; import ACard from "@/components/cards/ACard"; import BCard from "@/components/cards/BCard"; -import Window from "@/components/Window"; -import { WordData } from "./page"; +import { WordData, WordDataSchema } from "@/interfaces"; import { Dispatch, SetStateAction } from "react"; +import useFileUpload from "@/hooks/useFileUpload"; +import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; interface Props { wordData: WordData; + setWordData: Dispatch>; setPage: Dispatch>; } -export default function Main({ wordData, setPage: setPage }: Props) { +export default function Main({ + wordData, + setWordData, + setPage: setPage, +}: Props) { + const { upload, inputRef } = useFileUpload(async (file) => { + try { + const obj = JSON.parse(await file.text()); + const newWordData = WordDataSchema.parse(obj); + setWordData(newWordData); + } catch (error) { + console.error(error); + } + }); + const handleLoad = async () => { + upload("application/json"); + }; + const handleSave = () => { + const blob = new Blob([JSON.stringify(wordData)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "word_data.json"; + a.click(); + URL.revokeObjectURL(url); + }; return ( - +

Memorize @@ -21,20 +50,19 @@ export default function Main({ wordData, setPage: setPage }: Props) {

locale 1 {wordData.locales[0]}

locale 2 {wordData.locales[1]}

-

Total Words: {wordData.wordPairs.length}

+

total {wordData.wordPairs.length} word pairs

- setPage("start")}> - Start - - Load - Save - setPage("edit")}>Edit + setPage("start")}>开始 + 导入 + 保存 + setPage("edit")}>编辑
-
+ + ); } diff --git a/src/app/memorize/Start.tsx b/src/app/memorize/Start.tsx index 635d55a..5901954 100644 --- a/src/app/memorize/Start.tsx +++ b/src/app/memorize/Start.tsx @@ -1,51 +1,96 @@ import LightButton from "@/components/buttons/LightButton"; -import Window from "@/components/Window"; -import { WordData } from "./page"; +import { WordData } from "@/interfaces"; import { Dispatch, SetStateAction, useState } from "react"; +import { useAudioPlayer } from "@/hooks/useAudioPlayer"; +import { getTTSAudioUrl } from "@/utils"; +import { VOICES } from "@/config/locales"; +import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; + +interface WordBoardProps { + children: React.ReactNode; +} +function WordBoard({ children }: WordBoardProps) { + return ( +
+ {children} +
+ ); +} interface Props { wordData: WordData; setPage: Dispatch>; } - export default function Start({ wordData, setPage }: Props) { const [display, setDisplay] = useState<"ask" | "show">("ask"); const [wordPair, setWordPair] = useState( wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)], ); + const [reverse, setReverse] = useState(false); + const [dictation, setDictation] = useState(false); + const { load, play } = useAudioPlayer(); const show = () => { setDisplay("show"); }; - const next = () => { + const next = async () => { setDisplay("ask"); - setWordPair( - wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)], - ); + const newWordPair = + wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)]; + setWordPair(newWordPair); + if (dictation) + await load( + await getTTSAudioUrl( + newWordPair[reverse ? 1 : 0], + VOICES.find((v) => v.locale === wordData.locales[reverse ? 1 : 0])! + .short_name, + ), + ).then(play); }; return ( - -
-
-
- {wordPair[0]} -
- {display === "show" && ( -
- {wordPair[1]} -
+ +
+
+ {dictation ? ( + <> + {display === "show" && ( + <> + {wordPair[reverse ? 1 : 0]} + {wordPair[reverse ? 0 : 1]} + + )} + + ) : ( + <> + {wordPair[reverse ? 1 : 0]} + {display === "show" && ( + {wordPair[reverse ? 0 : 1]} + )} + )}
-
+
{display === "ask" ? ( Show ) : ( Next )} + setReverse(!reverse)} + selected={reverse} + > + Reverse + + setDictation(!dictation)} + selected={dictation} + > + Dictation + setPage("main")}>Exit
- + ); } diff --git a/src/app/memorize/page.tsx b/src/app/memorize/page.tsx index 886b18d..26720a1 100644 --- a/src/app/memorize/page.tsx +++ b/src/app/memorize/page.tsx @@ -4,30 +4,37 @@ import { useState } from "react"; import Main from "./Main"; import Edit from "./Edit"; import Start from "./Start"; - -export interface WordData { - locales: [string, string]; - wordPairs: [string, string][]; -} +import { WordData } from "@/interfaces"; export default function Memorize() { - const [page, setPage] = useState<"start" | "main" | "edit">( - "start", - ); + const [page, setPage] = useState<"start" | "main" | "edit">("main"); const [wordData, setWordData] = useState({ locales: ["en-US", "zh-CN"], wordPairs: [ - ['hello', '你好'], - ['world', '世界'], - ['brutal', '残酷的'], - ['apple', '苹果'], - ['banana', '香蕉'], - ['orange', '橙子'], - ['grape', '葡萄'], - ] + ["hello", "你好"], + ["world", "世界"], + ["brutal", "残酷的"], + ["apple", "苹果"], + ["banana", "香蕉"], + ["orange", "橙子"], + ["grape", "葡萄"], + ["San Francisco", "旧金山"], + ["New York", "纽约"], + ["Los Angeles", "洛杉矶"], + // ['A Very Very Very Very Very Very Very Long Word', '一个很长很长很长很长很长很长很长很长很长很长的单词'] + ["Chicago", "芝加哥"], + ["Tokyo", "东京"], + ["Paris", "巴黎"] + ], }); if (page === "main") - return
; + return ( +
+ ); if (page === "edit") return ( + +
+ {children} +
+ + ); +} diff --git a/src/components/NavbarWrapper.tsx b/src/components/NavbarWrapper.tsx new file mode 100644 index 0000000..0c79d1e --- /dev/null +++ b/src/components/NavbarWrapper.tsx @@ -0,0 +1,14 @@ +import { Navbar } from "./Navbar"; + +interface Props { + children: React.ReactNode; +} + +export default function NavbarWrapper({ children }: Props) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/Window.tsx b/src/components/Window.tsx deleted file mode 100644 index e535236..0000000 --- a/src/components/Window.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; -interface WindowProps { - children?: React.ReactNode; - className?: string; -} -export default function Window({ children }: WindowProps) { - return ( -
- {children} -
- ); -} diff --git a/src/components/cards/ACard.tsx b/src/components/cards/ACard.tsx index 6b8385e..08714aa 100644 --- a/src/components/cards/ACard.tsx +++ b/src/components/cards/ACard.tsx @@ -8,7 +8,7 @@ interface ACardProps { export default function ACard({ children, className }: ACardProps) { return (
{children}
diff --git a/src/hooks/useFileUpload.ts b/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..6576403 --- /dev/null +++ b/src/hooks/useFileUpload.ts @@ -0,0 +1,17 @@ +import { useRef } from "react"; + +export default function useFileUpload(callback: (file: File) => void) { + const inputRef = useRef(null); + const upload = (type: string = "*") => { + const input = inputRef.current; + if (input) { + input.click(); + input.setAttribute("accept", type); + input.onchange = () => { + const file = input.files?.[0]; + if (file) callback(file); + }; + } + }; + return { upload, inputRef }; +} diff --git a/src/interfaces.ts b/src/interfaces.ts index 1c0eda0..eb16490 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -22,3 +22,19 @@ export const TextSpeakerItemSchema = z.object({ locale: z.string(), }); export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); + +export const WordDataSchema = z.object({ + locales: z.tuple([z.string(), z.string()]) + .refine(([first, second]) => first !== second, { + message: "Locales must be different" + }), + wordPairs: z.array(z.tuple([z.string(), z.string()])) + .min(1, "At least one word pair is required") + .refine((pairs) => { + return pairs.every(([first, second]) => first.trim() !== '' && second.trim() !== ''); + }, { + message: "Word pairs cannot contain empty strings" + }) +}); + +export type WordData = z.infer;