完成了对记忆功能的升级
Some checks reported errors
continuous-integration/drone/push Build was killed

This commit is contained in:
2025-11-15 22:16:12 +08:00
parent cf3cb916b7
commit 72c6791d93
30 changed files with 363 additions and 450 deletions

View File

@@ -0,0 +1,102 @@
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {
Dispatch,
KeyboardEvent,
SetStateAction,
useEffect,
useState,
} from "react";
import { useTranslations } from "next-intl";
export default function MemoryCard({
alphabet,
setChosenAlphabet,
}: {
alphabet: Letter[];
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) {
const t = useTranslations("alphabet");
const [index, setIndex] = useState(
Math.floor(Math.random() * alphabet.length),
);
const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true);
useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === " ") refresh();
};
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
});
const letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
};
return (
<div
className="w-full flex justify-center items-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
>
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
<div className="w-full flex justify-end items-center">
<IconClick
size={32}
alt="close"
src={IMAGES.close}
onClick={() => setChosenAlphabet(null)}
></IconClick>
</div>
<div className="flex flex-col gap-12 justify-center items-center">
<span className="text-7xl md:text-9xl">
{letterDisplay ? letter.letter : ""}
</span>
<span className="text-5xl md:text-7xl text-gray-400">
{ipaDisplay ? letter.letter_sound_ipa : ""}
</span>
</div>
<div className="flex flex-row mt-32 items-center justify-center gap-2">
<IconClick
size={48}
alt="refresh"
src={IMAGES.refresh}
onClick={refresh}
></IconClick>
<IconClick
size={48}
alt="more"
src={IMAGES.more_horiz}
onClick={() => setMore(!more)}
></IconClick>
{more ? (
<>
<LightButton
className="w-20"
onClick={() => {
setLetterDisplay(!letterDisplay);
}}
>
{letterDisplay ? t("hideLetter") : t("showLetter")}
</LightButton>
<LightButton
className="w-20"
onClick={() => {
setIPADisplay(!ipaDisplay);
}}
>
{ipaDisplay ? t("hideIPA") : t("showIPA")}
</LightButton>
</>
) : (
<></>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard";
import { useTranslations } from "next-intl";
export default function Alphabet() {
const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] =
useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null,
english: null,
esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState("loading");
fetch("/alphabets/" + chosenAlphabet + ".json")
.then((res) => {
if (!res.ok) throw new Error("Network response was not ok");
return res.json();
})
.then((obj) => {
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success");
})
.catch(() => {
setLoadingState("error");
});
}
}, [chosenAlphabet, alphabetData]);
useEffect(() => {
if (loadingState === "error") {
const timer = setTimeout(() => {
setLoadingState("idle");
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
if (!chosenAlphabet)
return (
<>
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
<div className="flex gap-1 flex-wrap">
<LightButton onClick={() => setChosenAlphabet("japanese")}>
{t("japanese")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("english")}>
{t("english")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
{t("uyghur")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
{t("esperanto")}
</LightButton>
</div>
</div>
</>
);
if (loadingState === "loading") {
return t("loading");
}
if (loadingState === "error") {
return t("loadFailed");
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}
></MemoryCard>
</>
);
}
return null;
}

View File

@@ -0,0 +1,53 @@
"use client";
import Container from "@/components/cards/Container";
import { folder } from "../../../../generated/prisma/client";
import { Folder } from "lucide-react";
import { useRouter } from "next/navigation";
import { Center } from "@/components/Center";
interface FolderSelectorProps {
folders: (folder & { total_pairs: number })[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const router = useRouter();
return (
<Center>
<Container className="p-6 gap-4 flex flex-col">
{(folders.length === 0 && (
<h1 className="text-2xl text-gray-900 font-light">
No folders found.
</h1>
)) || (
<>
<h1 className="text-2xl text-gray-900 font-light">
Select a folder:
</h1>
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders.map((folder) => (
<div
key={folder.id}
onClick={() =>
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"
>
<Folder />
<div className="flex-1 flex gap-2">
<span className="group-hover:text-blue-500">
{folder.name}
</span>
<span>({folder.total_pairs})</span>
</div>
</div>
))}
</div>
</>
)}
</Container>
</Center>
);
};
export default FolderSelector;

View File

@@ -0,0 +1,115 @@
"use client";
import { Center } from "@/components/Center";
import { text_pair } from "../../../../generated/prisma/browser";
import Container from "@/components/cards/Container";
import { useState } from "react";
import LightButton from "@/components/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/tts";
import { VOICES } from "@/config/locales";
interface MemorizeProps {
textPairs: text_pair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
return (
<Center>
<Container className="p-6 flex flex-col gap-8 h-96 justify-center items-center">
{(textPairs.length > 0 && (
<>
<div className="h-36 flex flex-col gap-2 justify-start items-center font-serif text-3xl">
<div className="text-sm text-gray-500">
{index + 1}/{textPairs.length}
</div>
{dictation ? (
show === "question" ? (
""
) : (
<>
<div>
{reverse
? textPairs[index].text2
: textPairs[index].text1}
</div>
<div>
{reverse
? textPairs[index].text1
: textPairs[index].text2}
</div>
</>
)
) : (
<>
<div>
{reverse ? textPairs[index].text2 : textPairs[index].text1}
</div>
<div>
{show === "answer"
? reverse
? textPairs[index].text1
: textPairs[index].text2
: ""}
</div>
</>
)}
</div>
<div className="flex flex-row gap-2 items-center justify-center">
<LightButton
className="w-32"
onClick={async () => {
if (show === "answer") {
const newIndex = (index + 1) % textPairs.length;
setIndex(newIndex);
if (dictation)
getTTSAudioUrl(
textPairs[newIndex][reverse ? "text2" : "text1"],
VOICES.find(
(v) =>
v.locale ===
textPairs[newIndex][
reverse ? "locale2" : "locale1"
],
)!.short_name,
).then((url) => {
load(url);
play();
});
}
setShow(show === "question" ? "answer" : "question");
}}
>
{show === "question" ? "Show Answer" : "Next"}
</LightButton>
<LightButton
onClick={() => {
setReverse(!reverse);
}}
selected={reverse}
>
Reverse
</LightButton>
<LightButton
onClick={() => {
setDictation(!dictation);
}}
selected={dictation}
>
Dictation
</LightButton>
</div>
</>
)) || <p>No text pairs available</p>}
</Container>
</Center>
);
};
export default Memorize;

View File

@@ -0,0 +1,45 @@
"use server";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
getFoldersByOwner,
getFoldersWithTotalPairsByOwner,
getOwnerByFolderId,
} from "@/lib/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize";
import { getTextPairsByFolderId } from "@/lib/services/textPairService";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string }>;
}) {
const session = await getServerSession();
const username = session?.user?.name;
const t = (await searchParams).folder_id;
const folder_id = t ? (isNonNegativeInteger(t) ? parseInt(t) : null) : null;
if (!username)
redirect(
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
);
if (!folder_id) {
return (
<FolderSelector
folders={await getFoldersWithTotalPairsByOwner(username)}
/>
);
}
const owner = await getOwnerByFolderId(folder_id);
if (owner !== username) {
return <p>访</p>;
}
return <Memorize textPairs={await getTextPairsByFolderId(folder_id)} />;
}

View File

@@ -0,0 +1,48 @@
import LightButton from "@/components/buttons/LightButton";
import { useRef } from "react";
import { useTranslations } from "next-intl";
export default function UploadArea({
setVideoUrl,
setSrtUrl,
}: {
setVideoUrl: (url: string | null) => void;
setSrtUrl: (url: string | null) => void;
}) {
const t = useTranslations("srt-player");
const inputRef = useRef<HTMLInputElement>(null);
const uploadVideo = () => {
const input = inputRef.current;
if (input) {
input.setAttribute("accept", "video/*");
input.click();
input.onchange = () => {
const file = input.files?.[0];
if (file) {
setVideoUrl(URL.createObjectURL(file));
}
};
}
};
const uploadSRT = () => {
const input = inputRef.current;
if (input) {
input.setAttribute("accept", ".srt");
input.click();
input.onchange = () => {
const file = input.files?.[0];
if (file) {
setSrtUrl(URL.createObjectURL(file));
}
};
}
};
return (
<div className="w-full flex flex-col gap-2 m-2">
<LightButton onClick={uploadVideo}>{t("uploadVideo")}</LightButton>
<LightButton onClick={uploadSRT}>{t("uploadSubtitle")}</LightButton>
<input type="file" className="hidden" ref={inputRef} />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { inspect } from "@/lib/utils";
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => (
<span
onClick={inspect(v)}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + " "}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,216 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";
type VideoPanelProps = {
videoUrl: string | null;
srtUrl: string | null;
};
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
({ videoUrl, srtUrl }, videoRef) => {
const t = useTranslations("srt-player");
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
const [progress, setProgress] = useState<number>(-1);
const [autoPause, setAutoPause] = useState<boolean>(true);
const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef<
{ start: number; end: number; text: string }[] | null
>(null);
const rafldRef = useRef<number>(0);
const ready = useRef({
vid: false,
sub: false,
all: function () {
return this.vid && this.sub;
},
});
const togglePlayPause = useCallback(() => {
if (!videoUrl) return;
const video = videoRef.current;
if (!video) return;
if (video.paused || video.currentTime === 0) {
video.play();
} else {
video.pause();
}
setIsPlaying(!video.paused);
}, [videoRef, videoUrl]);
useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
if (e.key === "n") {
next();
} else if (e.key === "p") {
previous();
} else if (e.key === " ") {
togglePlayPause();
} else if (e.key === "r") {
restart();
} else if (e.key === "a") {
handleAutoPauseToggle();
}
};
document.addEventListener("keydown", handleKeyDownEvent);
return () => document.removeEventListener("keydown", handleKeyDownEvent);
});
useEffect(() => {
const cb = () => {
if (ready.current.all()) {
if (!parsedSrtRef.current) {
} else if (isPlaying) {
// 这里负责显示当前时间的字幕与自动暂停
const srt = parsedSrtRef.current;
const ct = videoRef.current?.currentTime as number;
const index = getIndex(srt, ct);
if (index !== null) {
setSubtitle(srt[index].text);
if (
autoPause &&
ct >= srt[index].end - 0.05 &&
ct < srt[index].end
) {
videoRef.current!.currentTime = srt[index].start;
togglePlayPause();
}
} else {
setSubtitle("");
}
} else {
}
}
rafldRef.current = requestAnimationFrame(cb);
};
rafldRef.current = requestAnimationFrame(cb);
return () => {
cancelAnimationFrame(rafldRef.current);
};
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => {
if (videoUrl && videoRef.current) {
videoRef.current.src = videoUrl;
videoRef.current.load();
setIsPlaying(false);
ready.current["vid"] = true;
}
}, [videoRef, videoUrl]);
useEffect(() => {
if (srtUrl) {
fetch(srtUrl)
.then((response) => response.text())
.then((data) => {
parsedSrtRef.current = parseSrt(data);
setSrtLength(parsedSrtRef.current.length);
ready.current["sub"] = true;
});
}
}, [srtUrl]);
const timeUpdate = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const index = getIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current && parsedSrtRef.current) {
const newProgress = parseInt(e.target.value);
videoRef.current.currentTime =
parsedSrtRef.current[newProgress]?.start || 0;
setProgress(newProgress);
}
};
const handleAutoPauseToggle = () => {
setAutoPause(!autoPause);
};
const next = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i + 1 < parsedSrtRef.current.length) {
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const previous = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i - 1 >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const restart = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i].start;
videoRef.current.play();
setIsPlaying(true);
}
};
return (
<div className="w-full flex flex-col">
<video
className="bg-gray-200"
ref={videoRef}
onTimeUpdate={timeUpdate}
></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap">
<LightButton onClick={togglePlayPause}>
{isPlaying ? t("pause") : t("play")}
</LightButton>
<LightButton onClick={previous}>{t("previous")}</LightButton>
<LightButton onClick={next}>{t("next")}</LightButton>
<LightButton onClick={restart}>{t("restart")}</LightButton>
<LightButton onClick={handleAutoPauseToggle}>
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
</LightButton>
</div>
<input
className="seekbar"
type="range"
min={0}
max={srtLength}
onChange={handleSeek}
step={1}
value={progress}
></input>
<span>{spanText}</span>
</div>
);
},
);
VideoPanel.displayName = "VideoPanel";
export default VideoPanel;

View File

@@ -0,0 +1,25 @@
"use client";
import { KeyboardEvent, useRef, useState } from "react";
import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel";
export default function SrtPlayerPage() {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [srtUrl, setSrtUrl] = useState<string | null>(null);
return (
<>
<div
className="flex w-screen pt-8 items-center justify-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
>
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} />
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,74 @@
export function parseSrt(data: string) {
const lines = data.split(/\r?\n/);
const result = [];
const re = new RegExp(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) {
i++;
continue;
}
i++;
if (i >= lines.length) break;
const timeMatch = lines[i].match(re);
if (!timeMatch) {
i++;
continue;
}
const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]);
i++;
let text = "";
while (i < lines.length && lines[i].trim()) {
text += lines[i] + "\n";
i++;
}
result.push({ start, end, text: text.trim() });
i++;
}
return result;
}
export function getNearistIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && !r) return i;
}
}
export function getIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
}
return null;
}
export function getSubtitle(
srt: { start: number; end: number; text: string }[],
currentTime: number,
) {
return (
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
null
);
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(",", ".").split(":");
return parseFloat(
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { getLocalStorageOperator } from "@/lib/utils";
import { useState } from "react";
import z from "zod";
import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
const onUseClick = () => {
handleUse(item);
};
const onDelClick = () => {
handleDel(item);
};
return (
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
{item.text}
</div>
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
{item.ipa}
</div>
</div>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}
></IconClick>
</div>
</div>
);
}
interface SaveListProps {
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text-speaker");
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
"text-speaker",
TextSpeakerArraySchema,
);
const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getFromLocalStorage();
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setIntoLocalStorage(current_data);
refresh();
};
const refresh = () => {
setData(getFromLocalStorage());
};
const handleDeleteAll = () => {
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setIntoLocalStorage([]);
refresh();
}
};
if (show)
return (
<div
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll}
size={48}
className=""
></IconClick>
</div>
<ul>
{data.map((v) => (
<TextCard
item={v}
key={crypto.randomUUID()}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>
))}
</ul>
</div>
);
else return <></>;
}

View File

@@ -0,0 +1,341 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/lib/interfaces";
import { getLocalStorageOperator, getTTSAudioUrl } from "@/lib/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod";
import SaveList from "./SaveList";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
export default function TextSpeakerPage() {
const t = useTranslations("text-speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false);
const [saving, setSaving] = useState(false);
const [ipaEnabled, setIPAEnabled] = useState(false);
const [speed, setSpeed] = useState(1);
const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true);
const textRef = useRef("");
const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
const { play, stop, load, audioRef } = useAudioPlayer();
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
typeof TextSpeakerArraySchema
>("text-speaker", TextSpeakerArraySchema);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
load(objurlRef.current!);
play();
}
};
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("ended", handleEnded);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]);
const speak = async () => {
if (processing) return;
setProcessing(true);
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({
text: textRef.current,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setIPA("");
});
}
if (pause) {
// 如果没在读
if (textRef.current.length === 0) {
// 没文本咋读
} else {
setPause(false);
if (objurlRef.current) {
// 之前有播放
load(objurlRef.current);
play();
} else {
// 第一次播放
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (
await fetch(`/api/locale?${params}`)
).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw "Voice not found.";
objurlRef.current = await getTTSAudioUrl(
textRef.current,
voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1)
return {
rate: `-${100 - speed * 100}%`,
};
else
return {
rate: `+${speed * 100 - 100}%`,
};
})(),
);
load(objurlRef.current);
play();
} catch (e) {
console.error(e);
setPause(true);
setLocale(null);
setProcessing(false);
}
}
}
} else {
// 如果在读就暂停
setPause(true);
stop();
}
setProcessing(false);
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLocale(null);
setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stop();
setPause(true);
};
const letMeSetSpeed = (new_speed: number) => {
return () => {
setSpeed(new_speed);
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stop();
setPause(true);
};
};
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text;
setLocale(item.locale);
setIPA(item.ipa || "");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stop();
setPause(true);
};
const save = async () => {
if (saving) return;
if (textRef.current.length === 0) return;
setSaving(true);
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: textRef.current,
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
}
const save = getFromLocalStorage();
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
if (theIPA) {
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
oldItem.ipa = theIPA;
setIntoLocalStorage(save);
}
}
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale,
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
ipa: theIPA,
});
}
setIntoLocalStorage(save);
} catch (e) {
console.error(e);
setLocale(null);
} finally {
setSaving(false);
}
};
return (
<>
<div
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<textarea
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
{ipa}
</div>
)) || <div className="h-18"></div>}
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{showSpeedAdjust && (
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
<IconClick
size={45}
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x}
alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x}
alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x}
alt="1x"
className={speed === 1 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x}
alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x}
alt="1.5x"
className={speed === 1.5 ? "bg-gray-200" : ""}
></IconClick>
</div>
)}
<IconClick
size={45}
onClick={speak}
src={pause ? IMAGES.play_arrow : IMAGES.pause}
alt="playorpause"
className={`${processing ? "bg-gray-200" : ""}`}
></IconClick>
<IconClick
size={45}
onClick={() => {
setAutopause(!autopause);
if (objurlRef) {
stop();
}
setPause(true);
}}
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
alt="autoplayorpause"
></IconClick>
<IconClick
size={45}
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed}
alt="speed"
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
></IconClick>
<IconClick
size={45}
onClick={save}
src={IMAGES.save}
alt="save"
className={`${saving ? "bg-gray-200" : ""}`}
></IconClick>
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<LightButton
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
{t("generateIPA")}
</LightButton>
<LightButton
onClick={() => {
setShowSaveList(!showSaveList);
}}
selected={showSaveList}
>
{t("viewSavedItems")}
</LightButton>
</div>
</div>
</div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</>
);
}

View File

@@ -0,0 +1,272 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import Container from "@/components/cards/Container";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/tts";
import { letsFetch } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import z from "zod";
export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>("");
const [genIpa, setGenIpa] = useState(true);
const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [processing, setProcessing] = useState(false);
const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[]
>(tlso.get());
const lastTTS = useRef({
text: "",
url: "",
});
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
const url = await getTTSAudioUrl(
text,
VOICES.find((v) => v.locale === locale)!.short_name,
);
await load(url);
lastTTS.current.text = text;
lastTTS.current.url = url;
}
play();
};
const translate = async () => {
if (processing) return;
setProcessing(true);
if (!taref.current) return;
const text = taref.current.value;
const newItem: {
text1: string | null;
text2: string | null;
locale1: string | null;
locale2: string | null;
} = {
text1: text,
text2: null,
locale1: null,
locale2: null,
};
const checkUpdateLocalStorage = (item: typeof newItem) => {
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
setHistory(tlso.get());
}
};
const innerStates = {
text2: false,
ipa1: !genIpa,
ipa2: !genIpa,
};
const checkUpdateProcessStates = () => {
if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2)
setProcessing(false);
};
const updateState = (stateName: keyof typeof innerStates) => () => {
innerStates[stateName] = true;
checkUpdateLocalStorage(newItem);
checkUpdateProcessStates();
};
// Fetch locale for text1
letsFetch(
`/api/v1/locale?text=${encodeURIComponent(text)}`,
(locale: string) => {
newItem.locale1 = locale;
},
console.log,
() => {},
);
if (genIpa)
// Fetch IPA for text1
letsFetch(
`/api/v1/ipa?text=${encodeURIComponent(text)}`,
(ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]),
console.log,
updateState("ipa1"),
);
// Fetch translation for text2
letsFetch(
`/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`,
(text2) => {
setTresult(text2);
newItem.text2 = text2;
if (genIpa)
// Fetch IPA for text2
letsFetch(
`/api/v1/ipa?text=${encodeURIComponent(text2)}`,
(ipa: string) => setIpaTexts((prev) => [prev[0], ipa]),
console.log,
updateState("ipa2"),
);
// Fetch locale for text2
letsFetch(
`/api/v1/locale?text=${encodeURIComponent(text2)}`,
(locale: string) => {
newItem.locale2 = locale;
},
console.log,
() => {},
);
},
console.log,
updateState("text2"),
);
};
return (
<>
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate();
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{ipaTexts[0]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}}
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
</div>
</div>
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-8/12 w-full">{tresult}</div>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{ipaTexts[1]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(tresult);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
tts(
tresult,
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
);
}}
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={lang === "english"}
onClick={() => setLang("english")}
>
{t("english")}
</LightButton>
<LightButton
selected={lang === "italian"}
onClick={() => setLang("italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => {
const newLang = prompt("Enter language");
if (newLang) {
setLang(newLang);
}
}}
>
{t("other")}
</LightButton>
</div>
</div>
</div>
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<button
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
onClick={translate}
>
{t("translate")}
</button>
</div>
{history.length > 0 && (
<Container className="m-6 flex flex-col p-6">
<h1 className="text-2xl font-light">History</h1>
<ul className="list-disc list-inside">
{history.map((item, index) => (
<li key={index}>
<span className="font-bold">{item.text1}</span> - {item.text2}
</li>
))}
</ul>
</Container>
)}
</>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import {
BOARD_WIDTH,
TEXT_WIDTH,
BOARD_HEIGHT,
TEXT_SIZE,
} from "@/config/word-board-config";
import { Word } from "@/lib/interfaces";
import { Dispatch, SetStateAction } from "react";
export default function TheBoard({
words,
selectWord,
}: {
words: [
{
word: string;
x: number;
y: number;
},
];
setWords: Dispatch<SetStateAction<Word[]>>;
selectWord: (word: string) => void;
}) {
function DraggableWord({ word }: { word: Word }) {
return (
<span
style={{
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
fontSize: `${TEXT_SIZE}px`,
}}
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
// onClick={inspect(word.word)}>{word.word}</span>))
onClick={() => {
selectWord(word.word);
}}
>
{word.word}
</span>
);
}
return (
<div
style={{
width: `${BOARD_WIDTH}px`,
height: `${BOARD_HEIGHT}px`,
}}
className="relative rounded bg-white"
>
{words.map(
(
v: {
word: string;
x: number;
y: number;
},
i: number,
) => {
return <DraggableWord word={v} key={i}></DraggableWord>;
},
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import TheBoard from "@/app/(features)/word-board/TheBoard";
import LightButton from "../../../components/buttons/LightButton";
import { KeyboardEvent, useRef, useState } from "react";
import { Word } from "@/lib/interfaces";
import {
BOARD_WIDTH,
TEXT_WIDTH,
BOARD_HEIGHT,
TEXT_SIZE,
} from "@/config/word-board-config";
import { inspect } from "@/lib/utils";
export default function WordBoardPage() {
const inputRef = useRef<HTMLInputElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null);
const initialWords = [
// 'apple',
// 'banana',
// 'cannon',
// 'desktop',
// 'kernel',
// 'system',
// 'programming',
// 'owe'
] as Array<string>;
const [words, setWords] = useState(
initialWords.map((v: string) => ({
word: v,
x: Math.random(),
y: Math.random(),
})),
);
const generateNewWord = (word: string) => {
const isOK = (w: Word) => {
if (words.length === 0) return true;
const tf = (ww: Word) =>
({
word: ww.word,
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
}) as Word;
const tfd_words = words.map(tf);
const tfd_w = tf(w);
for (const www of tfd_words) {
const p1 = {
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
y: (www.y + www.y + TEXT_SIZE) / 2,
};
const p2 = {
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2,
};
if (
Math.abs(p1.x - p2.x) <
(TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
Math.abs(p1.y - p2.y) < TEXT_SIZE
) {
return false;
}
}
return true;
};
let new_word;
let count = 0;
do {
new_word = {
word: word,
x: Math.random(),
y: Math.random(),
};
if (++count > 1000) return null;
} while (!isOK(new_word));
return new_word as Word;
};
const insertWord = () => {
if (!inputRef.current) return;
const word = inputRef.current.value.trim();
if (word === "") return;
const new_word = generateNewWord(word);
if (!new_word) return;
setWords([...words, new_word]);
inputRef.current.value = "";
};
const deleteWord = () => {
if (!inputRef.current) return;
const word = inputRef.current.value.trim();
if (word === "") return;
setWords(words.filter((v) => v.word !== word));
inputRef.current.value = "";
};
const importWords = () => {
inputFileRef.current?.click();
};
const exportWords = () => {
const blob = new Blob([JSON.stringify(words)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${Date.now()}.json`;
a.style.display = "none";
a.click();
URL.revokeObjectURL(url);
};
const handleFileChange = () => {
const files = inputFileRef.current?.files;
if (files && files.length > 0) {
const reader = new FileReader();
reader.onload = () => {
if (reader.result && typeof reader.result === "string")
setWords(JSON.parse(reader.result) as [Word]);
};
reader.readAsText(files[0]);
}
};
const deleteAll = () => {
setWords([] as Array<Word>);
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// e.preventDefault();
if (e.key === "Enter") {
insertWord();
}
};
const selectWord = (word: string) => {
if (!inputRef.current) return;
inputRef.current.value = word;
};
const searchWord = () => {
if (!inputRef.current) return;
const word = inputRef.current.value.trim();
if (word === "") return;
inspect(word)();
inputRef.current.value = "";
};
// const readWordAloud = () => {
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
// return;
// if (!inputRef.current) return;
// const word = inputRef.current.value.trim();
// if (word === '') return;
// inspect(word)();
// inputRef.current.value = '';
// }
return (
<>
<div className="flex w-screen h-screen justify-center items-center">
<div
onKeyDown={handleKeyDown}
className="p-5 bg-gray-200 rounded shadow-2xl"
>
<TheBoard
selectWord={selectWord}
words={words as [Word]}
setWords={setWords}
/>
<div className="flex justify-center rounded mt-3 gap-1">
<input
ref={inputRef}
placeholder="word to operate"
type="text"
className="focus:outline-none border-b-2 border-black"
/>
<LightButton onClick={insertWord}></LightButton>
<LightButton onClick={deleteWord}></LightButton>
<LightButton onClick={searchWord}></LightButton>
<LightButton onClick={importWords}></LightButton>
<LightButton onClick={exportWords}></LightButton>
<LightButton onClick={deleteAll}></LightButton>
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
</div>
<input
type="file"
ref={inputFileRef}
className="hidden"
accept="application/json"
onChange={handleFileChange}
></input>
</div>
</div>
</>
);
}