添加背单词功能
This commit is contained in:
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.3.65"],
|
allowedDevOrigins: ["192.168.3.65", "192.168.3.66"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
2025.10.30 添加背单词功能
|
||||||
2025.10.12 添加朗读器本地保存功能
|
2025.10.12 添加朗读器本地保存功能
|
||||||
2025.10.09 新增记忆字母表功能
|
2025.10.09 新增记忆字母表功能
|
||||||
2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项
|
2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
import ACard from "@/components/cards/ACard";
|
import ACard from "@/components/cards/ACard";
|
||||||
import BCard from "@/components/cards/BCard";
|
import BCard from "@/components/cards/BCard";
|
||||||
import Window from "@/components/Window";
|
|
||||||
import { LOCALES } from "@/config/locales";
|
import { LOCALES } from "@/config/locales";
|
||||||
import { Dispatch, SetStateAction, useState } from "react";
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
import { WordData } from "./page";
|
import { WordData } from "@/interfaces";
|
||||||
|
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
|
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
|
||||||
@@ -37,13 +37,13 @@ export default function Choose({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window>
|
<NavbarCenterWrapper className="bg-gray-100">
|
||||||
<ACard className="flex flex-col">
|
<ACard className="flex flex-col">
|
||||||
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-6 gap-2">
|
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-4 md:grid-cols-6 md:gap-2">
|
||||||
{LOCALES.map((locale, index) => (
|
{LOCALES.map((locale, index) => (
|
||||||
<LightButton
|
<LightButton
|
||||||
key={index}
|
key={index}
|
||||||
className="w-26"
|
className="md:w-26 w-18"
|
||||||
selected={locale === chosenLocale}
|
selected={locale === chosenLocale}
|
||||||
onClick={() => setChosenLocale(locale)}
|
onClick={() => setChosenLocale(locale)}
|
||||||
>
|
>
|
||||||
@@ -53,13 +53,11 @@ export default function Choose({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center justify-center">
|
<div className="w-full flex items-center justify-center">
|
||||||
<BCard className="flex gap-2 justify-center items-center w-fit">
|
<BCard className="flex gap-2 justify-center items-center w-fit">
|
||||||
<LightButton onClick={handleChooseClick}>choose</LightButton>
|
<LightButton onClick={handleChooseClick}>Choose</LightButton>
|
||||||
<LightButton onClick={() => setEditPage("edit")}>
|
<LightButton onClick={() => setEditPage("edit")}>Back</LightButton>
|
||||||
Back
|
|
||||||
</LightButton>
|
|
||||||
</BCard>
|
</BCard>
|
||||||
</div>
|
</div>
|
||||||
</ACard>
|
</ACard>
|
||||||
</Window>
|
</NavbarCenterWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
import ACard from "@/components/cards/ACard";
|
import ACard from "@/components/cards/ACard";
|
||||||
import BCard from "@/components/cards/BCard";
|
import BCard from "@/components/cards/BCard";
|
||||||
import Window from "@/components/Window";
|
import { ChangeEvent, Dispatch, SetStateAction, useRef, useState } from "react";
|
||||||
import { ChangeEvent, Dispatch, SetStateAction, useState } from "react";
|
|
||||||
import DarkButton from "@/components/buttons/DarkButton";
|
import DarkButton from "@/components/buttons/DarkButton";
|
||||||
import { WordData } from "./page";
|
import { WordData } from "@/interfaces";
|
||||||
import Choose from "./Choose";
|
import Choose from "./Choose";
|
||||||
|
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Edit({ setPage, wordData, setWordData }: Props) {
|
export default function Edit({ setPage, wordData, setWordData }: Props) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [localeKey, setLocaleKey] = useState<0 | 1>(0);
|
const [localeKey, setLocaleKey] = useState<0 | 1>(0);
|
||||||
const [editPage, setEditPage] = useState<"choose" | "edit">("edit");
|
const [editPage, setEditPage] = useState<"choose" | "edit">("edit");
|
||||||
const convertIntoWordData = (text: string) => {
|
const convertIntoWordData = (text: string) => {
|
||||||
@@ -33,44 +34,46 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
|
|||||||
locales: [...wordData.locales],
|
locales: [...wordData.locales],
|
||||||
wordPairs: t2,
|
wordPairs: t2,
|
||||||
};
|
};
|
||||||
setWordData(new_data);
|
return new_data;
|
||||||
};
|
};
|
||||||
const convertFromWordData = () => {
|
const convertFromWordData = (wdata: WordData) => {
|
||||||
let result = "";
|
let result = "";
|
||||||
for (const pair of wordData.wordPairs) {
|
for (const pair of wdata.wordPairs) {
|
||||||
result += `${pair[0]}, ${pair[1]}\n`;
|
result += `${pair[0]}, ${pair[1]}\n`;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
let input = convertFromWordData();
|
let input = convertFromWordData(wordData);
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
convertIntoWordData(input);
|
const newWordData = convertIntoWordData(input);
|
||||||
|
setWordData(newWordData);
|
||||||
|
if (textareaRef.current)
|
||||||
|
textareaRef.current.value = convertFromWordData(newWordData);
|
||||||
};
|
};
|
||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
input = e.target.value;
|
input = e.target.value;
|
||||||
};
|
};
|
||||||
if (editPage === "edit")
|
if (editPage === "edit")
|
||||||
return (
|
return (
|
||||||
<Window>
|
<NavbarCenterWrapper className="bg-gray-100">
|
||||||
<ACard className="flex flex-col">
|
<ACard className="flex flex-col">
|
||||||
<textarea
|
<textarea
|
||||||
className="flex-1 text-gray-800 font-serif text-2xl border-gray-200 border rounded-2xl w-full resize-none outline-0 p-2"
|
ref={textareaRef}
|
||||||
|
className="flex-1 text-gray-800 font-mono md:text-2xl border-gray-200 border rounded-2xl w-full resize-none outline-0 p-2"
|
||||||
defaultValue={input}
|
defaultValue={input}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div className="w-full flex items-center justify-center">
|
<div className="w-full flex items-center justify-center">
|
||||||
<BCard className="flex gap-2 justify-center items-center w-fit">
|
<BCard className="flex gap-2 justify-center items-center w-fit">
|
||||||
<LightButton onClick={() => setPage("main")}>
|
<LightButton onClick={() => setPage("main")}>Back</LightButton>
|
||||||
Back
|
<LightButton onClick={handleSave}>Save Pairs</LightButton>
|
||||||
</LightButton>
|
|
||||||
<LightButton onClick={handleSave}>Save Text</LightButton>
|
|
||||||
<DarkButton
|
<DarkButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocaleKey(0);
|
setLocaleKey(0);
|
||||||
setEditPage("choose");
|
setEditPage("choose");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Choose Locale 1
|
Locale 1
|
||||||
</DarkButton>
|
</DarkButton>
|
||||||
<DarkButton
|
<DarkButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -78,12 +81,12 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
|
|||||||
setEditPage("choose");
|
setEditPage("choose");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Choose Locale 2
|
Locale 2
|
||||||
</DarkButton>
|
</DarkButton>
|
||||||
</BCard>
|
</BCard>
|
||||||
</div>
|
</div>
|
||||||
</ACard>
|
</ACard>
|
||||||
</Window>
|
</NavbarCenterWrapper>
|
||||||
);
|
);
|
||||||
if (editPage === "choose")
|
if (editPage === "choose")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,47 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
import ACard from "@/components/cards/ACard";
|
import ACard from "@/components/cards/ACard";
|
||||||
import BCard from "@/components/cards/BCard";
|
import BCard from "@/components/cards/BCard";
|
||||||
import Window from "@/components/Window";
|
import { WordData, WordDataSchema } from "@/interfaces";
|
||||||
import { WordData } from "./page";
|
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
import useFileUpload from "@/hooks/useFileUpload";
|
||||||
|
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordData: WordData;
|
wordData: WordData;
|
||||||
|
setWordData: Dispatch<SetStateAction<WordData>>;
|
||||||
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Window>
|
<NavbarCenterWrapper className="bg-gray-100">
|
||||||
<ACard className="flex-col flex">
|
<ACard className="flex-col flex">
|
||||||
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
|
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
|
||||||
Memorize
|
Memorize
|
||||||
@@ -21,20 +50,19 @@ export default function Main({ wordData, setPage: setPage }: Props) {
|
|||||||
<BCard>
|
<BCard>
|
||||||
<p>locale 1 {wordData.locales[0]}</p>
|
<p>locale 1 {wordData.locales[0]}</p>
|
||||||
<p>locale 2 {wordData.locales[1]}</p>
|
<p>locale 2 {wordData.locales[1]}</p>
|
||||||
<p>Total Words: {wordData.wordPairs.length}</p>
|
<p>total {wordData.wordPairs.length} word pairs</p>
|
||||||
</BCard>
|
</BCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center justify-center">
|
<div className="w-full flex items-center justify-center">
|
||||||
<BCard className="flex gap-2 justify-center items-center w-fit">
|
<BCard className="flex gap-2 justify-center items-center w-fit">
|
||||||
<LightButton onClick={() => setPage("start")}>
|
<LightButton onClick={() => setPage("start")}>开始</LightButton>
|
||||||
Start
|
<LightButton onClick={handleLoad}>导入</LightButton>
|
||||||
</LightButton>
|
<LightButton onClick={handleSave}>保存</LightButton>
|
||||||
<LightButton>Load</LightButton>
|
<LightButton onClick={() => setPage("edit")}>编辑</LightButton>
|
||||||
<LightButton>Save</LightButton>
|
|
||||||
<LightButton onClick={() => setPage("edit")}>Edit</LightButton>
|
|
||||||
</BCard>
|
</BCard>
|
||||||
</div>
|
</div>
|
||||||
</ACard>
|
</ACard>
|
||||||
</Window>
|
<input type="file" hidden ref={inputRef}></input>
|
||||||
|
</NavbarCenterWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,96 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
import Window from "@/components/Window";
|
import { WordData } from "@/interfaces";
|
||||||
import { WordData } from "./page";
|
|
||||||
import { Dispatch, SetStateAction, useState } from "react";
|
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 (
|
||||||
|
<div className="text-nowrap w-full h-36 border border-white rounded flex justify-center items-center text-4xl md:text-6xl font-serif overflow-x-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordData: WordData;
|
wordData: WordData;
|
||||||
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Start({ wordData, setPage }: Props) {
|
export default function Start({ wordData, setPage }: Props) {
|
||||||
const [display, setDisplay] = useState<"ask" | "show">("ask");
|
const [display, setDisplay] = useState<"ask" | "show">("ask");
|
||||||
const [wordPair, setWordPair] = useState(
|
const [wordPair, setWordPair] = useState(
|
||||||
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
|
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 = () => {
|
const show = () => {
|
||||||
setDisplay("show");
|
setDisplay("show");
|
||||||
};
|
};
|
||||||
const next = () => {
|
const next = async () => {
|
||||||
setDisplay("ask");
|
setDisplay("ask");
|
||||||
setWordPair(
|
const newWordPair =
|
||||||
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
|
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 (
|
return (
|
||||||
<Window>
|
<NavbarCenterWrapper className="bg-gray-100">
|
||||||
<div className="flex-col flex items-center h-96 w-[66dvw]">
|
<div className="flex-col flex items-center h-96">
|
||||||
<div className="flex-1 w-full p-4 gap-4 flex flex-col text-5xl font-serif">
|
<div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
|
||||||
<div className="p-4 w-full border border-white rounded shadow">
|
{dictation ? (
|
||||||
{wordPair[0]}
|
<>
|
||||||
</div>
|
{display === "show" && (
|
||||||
{display === "show" && (
|
<>
|
||||||
<div className="p-4 w-full flex-1 border border-white rounded shadow">
|
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
|
||||||
{wordPair[1]}
|
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
|
||||||
|
{display === "show" && (
|
||||||
|
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center justify-center">
|
<div className="w-full flex items-center justify-center">
|
||||||
<div className="flex gap-2 justify-center items-center w-fit">
|
<div className="flex gap-2 justify-center items-center w-fit font-mono flex-wrap">
|
||||||
{display === "ask" ? (
|
{display === "ask" ? (
|
||||||
<LightButton onClick={show}>Show</LightButton>
|
<LightButton onClick={show}>Show</LightButton>
|
||||||
) : (
|
) : (
|
||||||
<LightButton onClick={next}>Next</LightButton>
|
<LightButton onClick={next}>Next</LightButton>
|
||||||
)}
|
)}
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setReverse(!reverse)}
|
||||||
|
selected={reverse}
|
||||||
|
>
|
||||||
|
Reverse
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setDictation(!dictation)}
|
||||||
|
selected={dictation}
|
||||||
|
>
|
||||||
|
Dictation
|
||||||
|
</LightButton>
|
||||||
<LightButton onClick={() => setPage("main")}>Exit</LightButton>
|
<LightButton onClick={() => setPage("main")}>Exit</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Window>
|
</NavbarCenterWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,37 @@ import { useState } from "react";
|
|||||||
import Main from "./Main";
|
import Main from "./Main";
|
||||||
import Edit from "./Edit";
|
import Edit from "./Edit";
|
||||||
import Start from "./Start";
|
import Start from "./Start";
|
||||||
|
import { WordData } from "@/interfaces";
|
||||||
export interface WordData {
|
|
||||||
locales: [string, string];
|
|
||||||
wordPairs: [string, string][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Memorize() {
|
export default function Memorize() {
|
||||||
const [page, setPage] = useState<"start" | "main" | "edit">(
|
const [page, setPage] = useState<"start" | "main" | "edit">("main");
|
||||||
"start",
|
|
||||||
);
|
|
||||||
const [wordData, setWordData] = useState<WordData>({
|
const [wordData, setWordData] = useState<WordData>({
|
||||||
locales: ["en-US", "zh-CN"],
|
locales: ["en-US", "zh-CN"],
|
||||||
wordPairs: [
|
wordPairs: [
|
||||||
['hello', '你好'],
|
["hello", "你好"],
|
||||||
['world', '世界'],
|
["world", "世界"],
|
||||||
['brutal', '残酷的'],
|
["brutal", "残酷的"],
|
||||||
['apple', '苹果'],
|
["apple", "苹果"],
|
||||||
['banana', '香蕉'],
|
["banana", "香蕉"],
|
||||||
['orange', '橙子'],
|
["orange", "橙子"],
|
||||||
['grape', '葡萄'],
|
["grape", "葡萄"],
|
||||||
]
|
["San Francisco", "旧金山"],
|
||||||
|
["New York", "纽约"],
|
||||||
|
["Los Angeles", "洛杉矶"],
|
||||||
|
// ['A Very Very Very Very Very Very Very Long Word', '一个很长很长很长很长很长很长很长很长很长很长的单词']
|
||||||
|
["Chicago", "芝加哥"],
|
||||||
|
["Tokyo", "东京"],
|
||||||
|
["Paris", "巴黎"]
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (page === "main")
|
if (page === "main")
|
||||||
return <Main wordData={wordData} setPage={setPage}></Main>;
|
return (
|
||||||
|
<Main
|
||||||
|
wordData={wordData}
|
||||||
|
setWordData={setWordData}
|
||||||
|
setPage={setPage}
|
||||||
|
></Main>
|
||||||
|
);
|
||||||
if (page === "edit")
|
if (page === "edit")
|
||||||
return (
|
return (
|
||||||
<Edit
|
<Edit
|
||||||
|
|||||||
@@ -64,10 +64,16 @@ function LinkGrid() {
|
|||||||
></LinkArea>
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/alphabet"
|
href="/alphabet"
|
||||||
name="记忆字母表"
|
name="背字母"
|
||||||
description="从字母表开始新语言的学习"
|
description="从字母表开始新语言的学习"
|
||||||
color="#dd7486"
|
color="#dd7486"
|
||||||
></LinkArea>
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/memorize"
|
||||||
|
name="背单词"
|
||||||
|
description="语言A到语言B,语言B到语言A,支持听写"
|
||||||
|
color="#cc9988"
|
||||||
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="#"
|
href="#"
|
||||||
name="更多功能"
|
name="更多功能"
|
||||||
|
|||||||
18
src/components/NavbarCenterWrapper.tsx
Normal file
18
src/components/NavbarCenterWrapper.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import NavbarWrapper from "./NavbarWrapper";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavbarCenterWrapper({ children, className }: Props) {
|
||||||
|
return (
|
||||||
|
<NavbarWrapper>
|
||||||
|
<div
|
||||||
|
className={`flex-1 flex justify-center items-center ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</NavbarWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/NavbarWrapper.tsx
Normal file
14
src/components/NavbarWrapper.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Navbar } from "./Navbar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavbarWrapper({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<Navbar></Navbar>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"use client";
|
|
||||||
interface WindowProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export default function Window({ children }: WindowProps) {
|
|
||||||
return (
|
|
||||||
<div className="w-full bg-gray-200 h-screen flex justify-center items-center">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ interface ACardProps {
|
|||||||
export default function ACard({ children, className }: ACardProps) {
|
export default function ACard({ children, className }: ACardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className} w-[61vw] h-96 p-2 shadow-2xl bg-white rounded-xl`}
|
className={`${className} w-[95dvw] md:w-[61vw] h-96 p-2 shadow-2xl bg-white rounded-xl`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
src/hooks/useFileUpload.ts
Normal file
17
src/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export default function useFileUpload(callback: (file: File) => void) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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 };
|
||||||
|
}
|
||||||
@@ -22,3 +22,19 @@ export const TextSpeakerItemSchema = z.object({
|
|||||||
locale: z.string(),
|
locale: z.string(),
|
||||||
});
|
});
|
||||||
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
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<typeof WordDataSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user