...
All checks were successful
continuous-integration/drone/push Build is passing

...

...

...

...
This commit is contained in:
2025-12-29 10:06:16 +08:00
parent d8f0117359
commit 5f24929116
42 changed files with 963 additions and 646 deletions

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import IconClick from "@/components/ui/buttons/IconClick";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -99,7 +99,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
{/* 返回按钮 */}
{/* 右上角返回按钮 */}
<div className="flex justify-end mb-4">
<IconClick
size={32}
@@ -110,13 +110,15 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
/>
</div>
{/* 主卡片 */}
{/* 白色主卡片容器 */}
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
{/* 进度指示器 */}
{/* 顶部进度指示器和显示选项按钮 */}
<div className="flex justify-between items-center mb-6">
{/* 当前字母进度 */}
<span className="text-sm text-gray-500">
{currentIndex + 1} / {alphabet.length}
</span>
{/* 显示选项切换按钮组 */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setShowLetter(!showLetter)}
@@ -128,6 +130,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
>
{t("letter")}
</button>
{/* IPA 音标显示切换 */}
<button
onClick={() => setShowIPA(!showIPA)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
@@ -138,6 +141,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
>
IPA
</button>
{/* 罗马音显示切换(仅日语显示) */}
{hasRomanization && (
<button
onClick={() => setShowRoman(!showRoman)}
@@ -150,6 +154,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
{t("roman")}
</button>
)}
{/* 随机模式切换 */}
<button
onClick={() => setIsRandomMode(!isRandomMode)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
@@ -163,8 +168,9 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
</div>
</div>
{/* 字母显示区域 */}
{/* 字母主要内容显示区域 */}
<div className="text-center mb-8">
{/* 字母本身(可隐藏) */}
{showLetter ? (
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
{currentLetter.letter}
@@ -174,13 +180,15 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
<span className="text-2xl md:text-3xl text-gray-400">?</span>
</div>
)}
{/* IPA 音标显示 */}
{showIPA && (
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
{currentLetter.letter_sound_ipa}
</div>
)}
{/* 罗马音显示(日语) */}
{showRoman && hasRomanization && currentLetter.roman_letter && (
<div className="text-lg md:text-xl text-gray-500">
{currentLetter.roman_letter}
@@ -188,8 +196,9 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
)}
</div>
{/* 导航控制 */}
{/* 底部导航控制区域 */}
<div className="flex justify-between items-center">
{/* 上一个按钮 */}
<button
onClick={goToPrevious}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
@@ -198,8 +207,10 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
<ChevronLeft size={24} />
</button>
{/* 中间区域:随机按钮或进度条 */}
<div className="flex gap-2 items-center">
{isRandomMode ? (
// 随机模式:显示随机切换按钮
<button
onClick={goToRandom}
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
@@ -207,6 +218,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
{t("randomNext")}
</button>
) : (
// 顺序模式:显示进度点
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
{alphabet.slice(0, 20).map((_, index) => (
<div
@@ -218,6 +230,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
}`}
/>
))}
{/* 超过20个字母时显示省略号 */}
{alphabet.length > 20 && (
<div className="text-xs text-gray-500 flex items-center">...</div>
)}
@@ -225,6 +238,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
)}
</div>
{/* 下一个按钮 */}
<button
onClick={goToNext}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
@@ -235,7 +249,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
</div>
</div>
{/* 操作提示 */}
{/* 底部操作提示文字 */}
<div className="text-center mt-6 text-white text-sm">
<p>
{isRandomMode
@@ -246,7 +260,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
</div>
</div>
{/* 触摸事件处理 */}
{/* 全屏触摸事件监听层(用于滑动切换) */}
<div
className="absolute inset-0 pointer-events-none"
onTouchStart={onTouchStart}

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import Container from "@/components/ui/Container";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import AlphabetCard from "./AlphabetCard";
export default function Alphabet() {
@@ -50,14 +50,18 @@ export default function Alphabet() {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
<Container className="p-8 max-w-2xl w-full text-center">
{/* 页面标题 */}
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("chooseCharacters")}
</h1>
{/* 副标题说明 */}
<p className="text-gray-600 mb-8 text-lg">
</p>
{/* 语言选择按钮网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 日语假名选项 */}
<LightButton
onClick={() => setChosenAlphabet("japanese")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
@@ -67,7 +71,8 @@ export default function Alphabet() {
<span>{t("japanese")}</span>
</div>
</LightButton>
{/* 英语字母选项 */}
<LightButton
onClick={() => setChosenAlphabet("english")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
@@ -77,7 +82,8 @@ export default function Alphabet() {
<span>{t("english")}</span>
</div>
</LightButton>
{/* 维吾尔语字母选项 */}
<LightButton
onClick={() => setChosenAlphabet("uyghur")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
@@ -87,7 +93,8 @@ export default function Alphabet() {
<span>{t("uyghur")}</span>
</div>
</LightButton>
{/* 世界语字母选项 */}
<LightButton
onClick={() => setChosenAlphabet("esperanto")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"

View File

@@ -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,83 @@ interface FolderSelectorProps {
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const t = useTranslations("memorize.folder_selector");
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">
{t("noFolders")}
<Link className="text-blue-900 border-b" href={"/folders"}>
folders
</Link>
</h1>
)) || (
<>
<h1 className="text-2xl text-gray-900 font-light">
{t("selectFolder")}
</h1>
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
>
<Fd />
<div className="flex-1 flex gap-2">
<span className="group-hover:text-blue-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</span>
</div>
</div>
))}
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
{folders.length === 0 ? (
// 空状态 - 显示提示和跳转按钮
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noFolders")}
</h1>
<Link
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
href="/folders"
>
Go to Folders
</Link>
</div>
</>
)}
</Container>
</Center>
) : (
<>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="flex-shrink-0">
<Fd className="text-gray-600" size={24} />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</div>
{/* 右箭头 */}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -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<MemorizeProps> = ({ textPairs }) => {
const { load, play } = useAudioPlayer();
if (textPairs.length === 0) {
return <p>{t("noTextPairs")}</p>;
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<p className="text-gray-700">{t("noTextPairs")}</p>
</div>
</div>
);
}
const rng = new SeededRandom(textPairs[0].folderId);
@@ -38,135 +43,160 @@ const Memorize: React.FC<MemorizeProps> = ({ 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 (
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
{text}
</div>
);
};
const [text1, text2] = reverse
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
return (
<>
{(getTextPairs().length > 0 && (
<>
<div className="text-center">
<div
className="text-sm text-gray-500"
onClick={() => {
const newIndex = prompt("Input a index number.")?.trim();
if (
newIndex &&
isNonNegativeInteger(newIndex) &&
parseInt(newIndex) <= textPairs.length &&
parseInt(newIndex) > 0
) {
setIndex(parseInt(newIndex) - 1);
}
}}
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<button
onClick={handleIndexClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
{index + 1}
{"/" + getTextPairs().length}
</div>
<div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
{(() => {
const createText = (text: string) => {
{index + 1} / {getTextPairs().length}
</button>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="text-gray-900 text-xl border-y border-y-gray-200 p-4 md:text-3xl h-[20dvh] overflow-y-auto">
{text}
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
};
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 (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
})()}
</div>
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
}
})()}
</div>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
className="w-20"
onClick={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");
}}
<button
onClick={handleNext}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
onClick={() => {
setIndex(
(index - 1 + getTextPairs().length) % getTextPairs().length,
);
setShow("question");
}}
</button>
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{t("previous")}
</LightButton>
<LightButton
onClick={() => {
setReverse(!reverse);
}}
selected={reverse}
</button>
<button
onClick={toggleReverse}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
reverse
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => {
setDictation(!dictation);
}}
selected={dictation}
</button>
<button
onClick={toggleDictation}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
dictation
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("dictation")}
</LightButton>
<LightButton
onClick={() => {
setDisorder(!disorder);
}}
selected={disorder}
</button>
<button
onClick={toggleDisorder}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
disorder
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("disorder")}
</LightButton>
</button>
</div>
</>
)) || <p>{t("noTextPairs")}</p>}
</>
</div>
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";

View File

@@ -2,7 +2,7 @@
import React from "react";
import { useTranslations } from "next-intl";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { PlayButtonProps } from "../../types/player";
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { SpeedControlProps } from "../../types/player";
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";

View File

@@ -3,7 +3,7 @@
import React from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { LightButton } from "@/components/ui/buttons";
import { ControlBarProps } from "../../types/controls";
import PlayButton from "../atoms/PlayButton";
import SpeedControl from "../atoms/SpeedControl";
@@ -31,32 +31,32 @@ export default function ControlBar({
disabled={disabled}
/>
<DarkButton
<LightButton
onClick={disabled ? undefined : onPrevious}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
{t("previous")}
</DarkButton>
</LightButton>
<DarkButton
<LightButton
onClick={disabled ? undefined : onNext}
disabled={disabled}
className="flex items-center px-3 py-2"
>
{t("next")}
<ChevronRight className="w-4 h-4 ml-2" />
</DarkButton>
</LightButton>
<DarkButton
<LightButton
onClick={disabled ? undefined : onRestart}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<RotateCcw className="w-4 h-4 mr-2" />
{t("restart")}
</DarkButton>
</LightButton>
<SpeedControl
playbackRate={playbackRate}
@@ -64,14 +64,14 @@ export default function ControlBar({
disabled={disabled}
/>
<DarkButton
<LightButton
onClick={disabled ? undefined : onAutoPauseToggle}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<Pause className="w-4 h-4 mr-2" />
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
</DarkButton>
</LightButton>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Video, FileText } from "lucide-react";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { LightButton } from "@/components/ui/buttons";
import { FileUploadProps } from "../../types/controls";
import { useFileUpload } from "../../hooks/useFileUpload";
@@ -26,21 +26,21 @@ export default function UploadZone({ onVideoUpload, onSubtitleUpload, className
return (
<div className={`flex gap-3 ${className || ''}`}>
<DarkButton
<LightButton
onClick={handleVideoUpload}
className="flex-1 py-2 px-3 text-sm"
>
<Video className="w-4 h-4 mr-2" />
{t("uploadVideo")}
</DarkButton>
</LightButton>
<DarkButton
<LightButton
onClick={handleSubtitleUpload}
className="flex-1 py-2 px-3 text-sm"
>
<FileText className="w-4 h-4 mr-2" />
{t("uploadSubtitle")}
</DarkButton>
</LightButton>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import SubtitleArea from "./components/compounds/SubtitleArea";
import ControlBar from "./components/compounds/ControlBar";
import UploadZone from "./components/compounds/UploadZone";
import SeekBar from "./components/atoms/SeekBar";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { LightButton } from "@/components/ui/buttons";
export default function SrtPlayerPage() {
const t = useTranslations("home");
@@ -182,13 +182,13 @@ export default function SrtPlayerPage() {
</p>
</div>
</div>
<DarkButton
<LightButton
onClick={state.video.url ? undefined : handleVideoUpload}
disabled={!!state.video.url}
className="px-2 py-1 text-xs"
>
{state.video.url ? srtT("uploaded") : srtT("upload")}
</DarkButton>
</LightButton>
</div>
</div>
@@ -206,13 +206,13 @@ export default function SrtPlayerPage() {
</p>
</div>
</div>
<DarkButton
<LightButton
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
disabled={!!state.subtitle.url}
className="px-2 py-1 text-xs"
>
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
</DarkButton>
</LightButton>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { SubtitleEntry } from "../types/subtitle";
import { logger } from "@/lib/logger";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
@@ -93,7 +94,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
const data = await response.text();
return parseSrt(data);
} catch (error) {
console.error('Failed to load subtitle:', error);
logger.error('加载字幕失败', error);
return [];
}
}

View File

@@ -6,7 +6,7 @@ import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import IconClick from "@/components/ui/buttons/IconClick";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";

View File

@@ -1,7 +1,7 @@
"use client";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
@@ -17,6 +17,8 @@ import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/server/translatorActions";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
@@ -74,7 +76,7 @@ export default function TextSpeakerPage() {
setIPA(data.ipa);
})
.catch((e) => {
console.error(e);
logger.error("生成 IPA 失败", e);
setIPA("");
});
}
@@ -95,7 +97,6 @@ export default function TextSpeakerPage() {
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
@@ -122,8 +123,7 @@ export default function TextSpeakerPage() {
load(objurlRef.current);
play();
} catch (e) {
console.error(e);
logger.error("播放音频失败", e);
setPause(true);
setLocale(null);
@@ -180,7 +180,6 @@ export default function TextSpeakerPage() {
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
@@ -217,7 +216,7 @@ export default function TextSpeakerPage() {
}
setIntoLocalStorage(save);
} catch (e) {
console.error(e);
logger.error("保存到本地存储失败", e);
setLocale(null);
} finally {
setSaving(false);
@@ -225,24 +224,30 @@ export default function TextSpeakerPage() {
};
return (
<>
<PageLayout className="items-start py-4">
{/* 文本输入区域 */}
<div
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
className="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"
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
{/* IPA 显示区域 */}
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
{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">
{/* 控制按钮区域 */}
<div className="p-4 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">
<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 z-10">
<IconClick
size={45}
onClick={letMeSetSpeed(0.5)}
@@ -280,6 +285,7 @@ export default function TextSpeakerPage() {
></IconClick>
</div>
)}
{/* 播放/暂停按钮 */}
<IconClick
size={45}
onClick={speak}
@@ -287,6 +293,7 @@ export default function TextSpeakerPage() {
alt="playorpause"
className={`${processing ? "bg-gray-200" : ""}`}
></IconClick>
{/* 自动暂停按钮 */}
<IconClick
size={45}
onClick={() => {
@@ -299,6 +306,7 @@ export default function TextSpeakerPage() {
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
alt="autoplayorpause"
></IconClick>
{/* 速度调节按钮 */}
<IconClick
size={45}
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
@@ -306,6 +314,7 @@ export default function TextSpeakerPage() {
alt="speed"
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
></IconClick>
{/* 保存按钮 */}
<IconClick
size={45}
onClick={save}
@@ -313,6 +322,7 @@ export default function TextSpeakerPage() {
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}
@@ -331,7 +341,12 @@ export default function TextSpeakerPage() {
</div>
</div>
</div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</>
{/* 保存列表 */}
{showSaveList && (
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</div>
)}
</PageLayout>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { Dispatch, useEffect, useState } from "react";

View File

@@ -2,7 +2,7 @@ import Container from "@/components/ui/Container";
import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {

View File

@@ -1,13 +1,14 @@
"use client";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
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/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { logger } from "@/lib/logger";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
@@ -67,7 +68,7 @@ export default function TranslatorPage() {
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
console.error(error);
logger.error("生成音频失败", error);
}
}
await play();