...
... ... ...
This commit is contained in:
@@ -24,11 +24,7 @@
|
||||
"noFoldersYet": "No folders yet",
|
||||
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||
"enterFolderName": "Enter folder name:",
|
||||
"confirmDelete": "Type \"{name}\" to delete:",
|
||||
"createFolderSuccess": "Folder created successfully",
|
||||
"deleteFolderSuccess": "Folder deleted successfully",
|
||||
"createFolderError": "Failed to create folder",
|
||||
"deleteFolderError": "Failed to delete folder"
|
||||
"confirmDelete": "Type \"{name}\" to delete:"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "You are not the owner of this folder",
|
||||
@@ -82,10 +78,6 @@
|
||||
"description": "Under development, stay tuned"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"loading": "Loading...",
|
||||
"githubLogin": "GitHub Login"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"signIn": "Sign In",
|
||||
@@ -93,18 +85,11 @@
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"name": "Name",
|
||||
"signInButton": "Sign In",
|
||||
"signUpButton": "Sign Up",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signInWithGitHub": "Sign In with GitHub",
|
||||
"signUpWithGitHub": "Sign Up with GitHub",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordsNotMatch": "Passwords do not match",
|
||||
"signInFailed": "Sign in failed, please check your email and password",
|
||||
"signUpFailed": "Sign up failed, please try again later",
|
||||
"nameRequired": "Please enter your name",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password",
|
||||
@@ -151,42 +136,10 @@
|
||||
"next": "Next",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Auto Pause ({enabled})",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"subtitleSettings": "Subtitle Settings",
|
||||
"fontSize": "Font Size",
|
||||
"backgroundColor": "Background Color",
|
||||
"textColor": "Text Color",
|
||||
"fontFamily": "Font Family",
|
||||
"opacity": "Opacity",
|
||||
"position": "Position",
|
||||
"top": "Top",
|
||||
"center": "Center",
|
||||
"bottom": "Bottom",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||
"uploadVideoFile": "Please upload video file",
|
||||
"uploadSubtitleFile": "Please upload subtitle file",
|
||||
"processingSubtitle": "Processing subtitle file...",
|
||||
"needBothFiles": "Both video and subtitle files are required to start learning",
|
||||
"videoFile": "Video File",
|
||||
"subtitleFile": "Subtitle File",
|
||||
"uploaded": "Uploaded",
|
||||
"notUploaded": "Not Uploaded",
|
||||
"upload": "Upload",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle file loading failed",
|
||||
"shortcuts": {
|
||||
"playPause": "Play/Pause",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Toggle Auto Pause"
|
||||
}
|
||||
"subtitleUploadFailed": "Subtitle upload failed"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
|
||||
@@ -24,11 +24,7 @@
|
||||
"noFoldersYet": "还没有文件夹",
|
||||
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||
"enterFolderName": "输入文件夹名称:",
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||
"createFolderSuccess": "文件夹创建成功",
|
||||
"deleteFolderSuccess": "文件夹删除成功",
|
||||
"createFolderError": "创建文件夹失败",
|
||||
"deleteFolderError": "删除文件夹失败"
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "您不是此文件夹的所有者",
|
||||
@@ -82,10 +78,6 @@
|
||||
"description": "开发中,敬请期待"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"loading": "加载中...",
|
||||
"githubLogin": "GitHub登录"
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录",
|
||||
"signIn": "登录",
|
||||
@@ -93,28 +85,18 @@
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"name": "用户名",
|
||||
"signInButton": "登录",
|
||||
"signUpButton": "注册",
|
||||
"noAccount": "还没有账户?",
|
||||
"hasAccount": "已有账户?",
|
||||
"signInWithGitHub": "使用GitHub登录",
|
||||
"signUpWithGitHub": "使用GitHub注册",
|
||||
"invalidEmail": "请输入有效的邮箱地址",
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||
"signInFailed": "登录失败,请检查您的邮箱和密码",
|
||||
"signUpFailed": "注册失败,请稍后再试",
|
||||
"nameRequired": "请输入用户名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码"
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"memorize": {
|
||||
"choose": {
|
||||
"back": "返回",
|
||||
"choose": "选择"
|
||||
},
|
||||
"folder_selector": {
|
||||
"selectFolder": "选择文件夹",
|
||||
"noFolders": "未找到文件夹",
|
||||
@@ -155,41 +137,10 @@
|
||||
"next": "下句",
|
||||
"restart": "句首",
|
||||
"autoPause": "自动暂停({enabled})",
|
||||
"playbackSpeed": "播放速度",
|
||||
"subtitleSettings": "字幕设置",
|
||||
"fontSize": "字体大小",
|
||||
"backgroundColor": "背景颜色",
|
||||
"textColor": "文字颜色",
|
||||
"fontFamily": "字体",
|
||||
"opacity": "透明度",
|
||||
"position": "位置",
|
||||
"top": "顶部",
|
||||
"center": "居中",
|
||||
"bottom": "底部",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||
"uploadVideoFile": "请上传视频文件",
|
||||
"uploadSubtitleFile": "请上传字幕文件",
|
||||
"processingSubtitle": "字幕文件正在处理中...",
|
||||
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
|
||||
"videoFile": "视频文件",
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕文件加载成功",
|
||||
"subtitleLoadFailed": "字幕文件加载失败",
|
||||
"shortcuts": {
|
||||
"playPause": "播放/暂停",
|
||||
"next": "下一句",
|
||||
"previous": "上一句",
|
||||
"restart": "句首",
|
||||
"autoPause": "切换自动暂停"
|
||||
}
|
||||
"subtitleUploadFailed": "字幕上传失败"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
||||
import Container from "@/components/ui/Container";
|
||||
import Input from "@/components/ui/Input";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface AuthFormProps {
|
||||
@@ -18,7 +17,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [clearSignIn, setClearSignIn] = useState(false);
|
||||
const [clearSignUp, setClearSignUp] = useState(false);
|
||||
|
||||
|
||||
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
||||
async (prevState: SignUpState | undefined, formData: FormData) => {
|
||||
if (clearSignIn) {
|
||||
@@ -44,7 +43,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
|
||||
const validateForm = (formData: FormData): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const name = formData.get("name") as string;
|
||||
@@ -66,7 +65,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
if (!name) {
|
||||
newErrors.name = t("nameRequired");
|
||||
}
|
||||
|
||||
|
||||
if (!confirmPassword) {
|
||||
newErrors.confirmPassword = t("confirmPasswordRequired");
|
||||
} else if (password !== confirmPassword) {
|
||||
@@ -81,17 +80,17 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
|
||||
// 基本客户端验证
|
||||
if (!validateForm(formData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 添加 redirectTo 到 formData
|
||||
if (redirectTo) {
|
||||
formData.append("redirectTo", redirectTo);
|
||||
}
|
||||
|
||||
|
||||
// 使用 startTransition 包装 action 调用
|
||||
startTransition(() => {
|
||||
// 根据模式调用相应的 action
|
||||
@@ -115,17 +114,21 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||
<Container className="p-8 max-w-md w-full">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 服务器端错误提示 */}
|
||||
{currentError?.message && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{currentError.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录/注册表单 */}
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
{/* 用户名输入(仅注册模式显示) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<Input
|
||||
@@ -134,15 +137,18 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
placeholder={t("name")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{/* 客户端验证错误 */}
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||
)}
|
||||
{/* 服务器端验证错误 */}
|
||||
{currentError?.errors?.username && (
|
||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 邮箱输入 */}
|
||||
<div>
|
||||
<Input
|
||||
type="email"
|
||||
@@ -158,6 +164,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -173,6 +180,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认密码输入(仅注册模式显示) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<Input
|
||||
@@ -187,18 +195,21 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DarkButton
|
||||
{/* 提交按钮 */}
|
||||
<LightButton
|
||||
type="submit"
|
||||
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isSignInPending || isSignUpPending
|
||||
? t("loading")
|
||||
{isSignInPending || isSignUpPending
|
||||
? t("loading")
|
||||
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
||||
}
|
||||
</DarkButton>
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
{/* 第三方登录区域 */}
|
||||
<div className="mt-6">
|
||||
{/* 分隔线 */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
@@ -208,6 +219,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub 登录按钮 */}
|
||||
<LightButton
|
||||
onClick={handleGitHubSignIn}
|
||||
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
||||
@@ -219,6 +231,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
{/* 模式切换链接 */}
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
@@ -234,7 +247,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
}}
|
||||
className="text-[#35786f] hover:underline"
|
||||
>
|
||||
{mode === 'signin'
|
||||
{mode === 'signin'
|
||||
? `${t("noAccount")} ${t("signUp")}`
|
||||
: `${t("hasAccount")} ${t("signIn")}`
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Folder } from "../../../generated/prisma/browser";
|
||||
import {
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from "@/lib/server/services/folderService";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import PageLayout from "@/components/ui/PageLayout";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import CardList from "@/components/ui/CardList";
|
||||
|
||||
interface FolderProps {
|
||||
folder: Folder & { total: number };
|
||||
@@ -27,8 +30,8 @@ interface FolderProps {
|
||||
|
||||
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const t = useTranslations("folders");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
@@ -37,24 +40,23 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-linear-to-br from-blue-50 to-blue-100 flex items-center justify-center group-hover:from-blue-100 group-hover:to-blue-200 transition-colors">
|
||||
<Fd></Fd>
|
||||
<div className="shrink-0">
|
||||
<Fd className="text-gray-600" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
totalPairs: folder.total,
|
||||
})}
|
||||
</h3>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400">#{folder.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -63,7 +65,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
renameFolderById(folder.id, newName).then(refresh);
|
||||
}
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:bg-red-50 rounded-lg transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<FolderPen size={16} />
|
||||
</button>
|
||||
@@ -100,7 +102,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
logger.error("加载文件夹失败", error);
|
||||
toast.error("加载出错,请重试。");
|
||||
});
|
||||
}, [userId]);
|
||||
@@ -110,48 +112,50 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
||||
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
||||
setFolders(updatedFolders);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error("更新文件夹失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<div className="w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-light text-gray-900">{t("title")}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{t("subtitle")}</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
user: { connect: { id: userId } },
|
||||
});
|
||||
await updateFolders();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FolderPlus size={18} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</button>
|
||||
{/* 新建文件夹按钮 */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
user: { connect: { id: userId } },
|
||||
});
|
||||
await updateFolders();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FolderPlus size={18} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-4 max-h-96 overflow-y-auto">
|
||||
{/* 文件夹列表 */}
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{folders.length === 0 ? (
|
||||
// 空状态
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
// 文件夹卡片列表
|
||||
<div className="rounded-xl border border-gray-200 overflow-hidden">
|
||||
{folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
@@ -164,8 +168,8 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardList>
|
||||
</div>
|
||||
</Center>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AddTextPairModalProps {
|
||||
@@ -23,23 +24,23 @@ export default function AddTextPairModal({
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const input3Ref = useRef<HTMLInputElement>(null);
|
||||
const input4Ref = useRef<HTMLInputElement>(null);
|
||||
const [locale1, setLocale1] = useState("en-US");
|
||||
const [locale2, setLocale2] = useState("zh-CN");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!input3Ref.current?.value ||
|
||||
!input4Ref.current?.value
|
||||
!locale1 ||
|
||||
!locale2
|
||||
)
|
||||
return;
|
||||
|
||||
const text1 = input1Ref.current.value;
|
||||
const text2 = input2Ref.current.value;
|
||||
const locale1 = input3Ref.current.value;
|
||||
const locale2 = input4Ref.current.value;
|
||||
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
@@ -55,6 +56,7 @@ export default function AddTextPairModal({
|
||||
input2Ref.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||
@@ -83,19 +85,11 @@ export default function AddTextPairModal({
|
||||
</div>
|
||||
<div>
|
||||
{t("locale1")}
|
||||
<Input
|
||||
ref={input3Ref}
|
||||
className="w-full"
|
||||
placeholder="en-US"
|
||||
></Input>
|
||||
<LocaleSelector value={locale1} onChange={setLocale1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("locale2")}
|
||||
<Input
|
||||
ref={input4Ref}
|
||||
className="w-full"
|
||||
placeholder="zh-CN"
|
||||
></Input>
|
||||
<LocaleSelector value={locale2} onChange={setLocale2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import Container from "@/components/ui/Container";
|
||||
import {
|
||||
createPair,
|
||||
deletePairById,
|
||||
@@ -12,8 +10,12 @@ import {
|
||||
} from "@/lib/server/services/pairService";
|
||||
import AddTextPairModal from "./AddTextPairModal";
|
||||
import TextPairCard from "./TextPairCard";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { useTranslations } from "next-intl";
|
||||
import PageLayout from "@/components/ui/PageLayout";
|
||||
import { GreenButton } from "@/components/ui/buttons";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { IconButton } from "@/components/ui/buttons";
|
||||
import CardList from "@/components/ui/CardList";
|
||||
|
||||
export interface TextPair {
|
||||
id: number;
|
||||
@@ -37,7 +39,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
||||
const data = await getPairsByFolderId(folderId);
|
||||
setTextPairs(data as TextPair[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch text pairs:", error);
|
||||
logger.error("获取文本对失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -50,84 +52,88 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
||||
const data = await getPairsByFolderId(folderId);
|
||||
setTextPairs(data as TextPair[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch text pairs:", error);
|
||||
logger.error("获取文本对失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</button>
|
||||
<PageLayout>
|
||||
{/* 顶部导航和标题栏 */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-light text-gray-900">
|
||||
{t("textPairs")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("itemsCount", { count: textPairs.length })}
|
||||
</p>
|
||||
</div>
|
||||
{/* 页面标题和操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 标题区域 */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{t("textPairs")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: textPairs.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LightButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?folder_id=${folderId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</LightButton>
|
||||
<button
|
||||
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus
|
||||
size={18}
|
||||
className="text-gray-600 hover:cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GreenButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?folder_id=${folderId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</GreenButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
icon={<Plus size={18} className="text-gray-700" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||
</div>
|
||||
) : textPairs.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{textPairs
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((textPair) => (
|
||||
<TextPairCard
|
||||
key={textPair.id}
|
||||
textPair={textPair}
|
||||
onDel={() => {
|
||||
deletePairById(textPair.id);
|
||||
refreshTextPairs();
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
{/* 文本对列表 */}
|
||||
<CardList>
|
||||
{loading ? (
|
||||
// 加载状态
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||
</div>
|
||||
) : textPairs.length === 0 ? (
|
||||
// 空状态
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||
</div>
|
||||
) : (
|
||||
// 文本对卡片列表
|
||||
<div className="divide-y divide-gray-100">
|
||||
{textPairs
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((textPair) => (
|
||||
<TextPairCard
|
||||
key={textPair.id}
|
||||
textPair={textPair}
|
||||
onDel={() => {
|
||||
deletePairById(textPair.id);
|
||||
refreshTextPairs();
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
{/* 添加文本对模态框 */}
|
||||
<AddTextPairModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
@@ -151,6 +157,6 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
||||
refreshTextPairs();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
||||
import { TextPair } from "./InFolder";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -22,23 +23,23 @@ export default function UpdateTextPairModal({
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const input3Ref = useRef<HTMLInputElement>(null);
|
||||
const input4Ref = useRef<HTMLInputElement>(null);
|
||||
const [locale1, setLocale1] = useState(textPair.locale1);
|
||||
const [locale2, setLocale2] = useState(textPair.locale2);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!input3Ref.current?.value ||
|
||||
!input4Ref.current?.value
|
||||
!locale1 ||
|
||||
!locale2
|
||||
)
|
||||
return;
|
||||
|
||||
const text1 = input1Ref.current.value;
|
||||
const text2 = input2Ref.current.value;
|
||||
const locale1 = input3Ref.current.value;
|
||||
const locale2 = input4Ref.current.value;
|
||||
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
@@ -50,8 +51,6 @@ export default function UpdateTextPairModal({
|
||||
locale2.trim() !== ""
|
||||
) {
|
||||
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
|
||||
input1Ref.current.value = "";
|
||||
input2Ref.current.value = "";
|
||||
}
|
||||
};
|
||||
return (
|
||||
@@ -90,19 +89,11 @@ export default function UpdateTextPairModal({
|
||||
</div>
|
||||
<div>
|
||||
{t("locale1")}
|
||||
<Input
|
||||
defaultValue={textPair.locale1}
|
||||
ref={input3Ref}
|
||||
className="w-full"
|
||||
></Input>
|
||||
<LocaleSelector value={locale1} onChange={setLocale1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("locale2")}
|
||||
<Input
|
||||
defaultValue={textPair.locale2}
|
||||
ref={input4Ref}
|
||||
className="w-full"
|
||||
></Input>
|
||||
<LocaleSelector value={locale2} onChange={setLocale2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import Container from "@/components/ui/Container";
|
||||
import PageLayout from "@/components/ui/PageLayout";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { auth } from "@/auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -16,25 +16,34 @@ export default async function ProfilePage() {
|
||||
redirect("/auth?redirect=/profile");
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(session, null, 2));
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6">
|
||||
<h1>{t("myProfile")}</h1>
|
||||
<PageLayout>
|
||||
<PageHeader title={t("myProfile")} />
|
||||
|
||||
{/* 用户信息区域 */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* 用户头像 */}
|
||||
{session.user.image && (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
width={80}
|
||||
height={80}
|
||||
alt="User Avatar"
|
||||
src={session.user.image as string}
|
||||
className="rounded-4xl"
|
||||
></Image>
|
||||
className="rounded-full"
|
||||
/>
|
||||
)}
|
||||
<p>{session.user.name}</p>
|
||||
<p>{t("email", { email: session.user.email })}</p>
|
||||
|
||||
{/* 用户名和邮箱 */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{session.user.name}
|
||||
</h2>
|
||||
<p className="text-gray-600">{t("email", { email: session.user.email })}</p>
|
||||
</div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<LogoutButton />
|
||||
</Container>
|
||||
</Center>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import IMAGES from "@/config/images";
|
||||
import IconClick from "./ui/buttons/IconClick";
|
||||
import { IconClick, GhostButton } from "./ui/buttons";
|
||||
import { useState } from "react";
|
||||
import GhostButton from "./ui/buttons/GhostButton";
|
||||
|
||||
export default function LanguageSettings() {
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
@@ -21,6 +20,7 @@ export default function LanguageSettings() {
|
||||
alt="language"
|
||||
disableOnHoverBgChange={true}
|
||||
onClick={handleLanguageClick}
|
||||
size={40}
|
||||
></IconClick>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Folder, Home } from "lucide-react";
|
||||
import { Folder, Home, User } from "lucide-react";
|
||||
import LanguageSettings from "../LanguageSettings";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import GhostButton from "../ui/buttons/GhostButton";
|
||||
import { GhostButton } from "../ui/buttons";
|
||||
|
||||
export async function Navbar() {
|
||||
const t = await getTranslations("navbar");
|
||||
@@ -14,46 +14,56 @@ export async function Navbar() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
||||
<GhostButton href="/" className="text-xl border-b hidden md:block">
|
||||
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
|
||||
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!">
|
||||
{t("title")}
|
||||
</GhostButton>
|
||||
<GhostButton className="block md:hidden" href={"/"}>
|
||||
<Home />
|
||||
<GhostButton className="block! md:hidden!" href={"/"}>
|
||||
<Home size={20} />
|
||||
</GhostButton>
|
||||
<div className="flex text-xl gap-0.5 justify-center items-center flex-wrap">
|
||||
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap">
|
||||
<LanguageSettings />
|
||||
<GhostButton
|
||||
className="md:hidden block"
|
||||
className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark_white}
|
||||
alt="GitHub"
|
||||
width={24}
|
||||
height={24}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</GhostButton>
|
||||
<LanguageSettings />
|
||||
<GhostButton href="/folders" className="md:block hidden">
|
||||
<GhostButton href="/folders" className="md:block! hidden! border-0 bg-transparent hover:bg-black/30 shadow-none">
|
||||
{t("folders")}
|
||||
</GhostButton>
|
||||
<GhostButton href="/folders" className="md:hidden block">
|
||||
<Folder />
|
||||
<GhostButton href="/folders" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||
<Folder size={20} />
|
||||
</GhostButton>
|
||||
{
|
||||
(() => {
|
||||
return session &&
|
||||
<GhostButton href="/profile">{t("profile")}</GhostButton>
|
||||
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
|
||||
|
||||
})()
|
||||
}
|
||||
<GhostButton
|
||||
className="hidden md:block"
|
||||
className="hidden! md:block! border-0 bg-transparent hover:bg-black/30 shadow-none"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
{t("sourceCode")}
|
||||
</GhostButton>
|
||||
{
|
||||
(() => {
|
||||
return session &&
|
||||
<>
|
||||
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("profile")}</GhostButton>
|
||||
<GhostButton href="/profile" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||
<User size={20} />
|
||||
</GhostButton>
|
||||
</>
|
||||
|| <>
|
||||
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("sign_in")}</GhostButton>
|
||||
<GhostButton href="/auth" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||
<User size={20} />
|
||||
</GhostButton>
|
||||
</>;
|
||||
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
163
src/components/ui/Button.tsx
Normal file
163
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { COLORS } from "@/lib/theme/colors";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps {
|
||||
// Content
|
||||
children?: React.ReactNode;
|
||||
|
||||
// Behavior
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
|
||||
// Styling
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// Icons
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
iconSrc?: string; // For Next.js Image icons
|
||||
iconAlt?: string;
|
||||
|
||||
// Navigation
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
selected = false,
|
||||
href,
|
||||
iconSrc,
|
||||
iconAlt,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
type = "button",
|
||||
disabled = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
// Base classes
|
||||
const baseClasses = "inline-flex items-center justify-center gap-2 rounded font-bold shadow hover:cursor-pointer transition-colors";
|
||||
|
||||
// Variant-specific classes
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
text-white
|
||||
hover:opacity-90
|
||||
`,
|
||||
secondary: `
|
||||
text-black
|
||||
hover:bg-gray-100
|
||||
`,
|
||||
ghost: `
|
||||
hover:bg-black/30
|
||||
p-2
|
||||
`,
|
||||
icon: `
|
||||
p-2 bg-gray-200 rounded-full
|
||||
hover:bg-gray-300
|
||||
`
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1 text-sm",
|
||||
md: "px-4 py-2",
|
||||
lg: "px-6 py-3 text-lg"
|
||||
};
|
||||
|
||||
const variantClass = variantStyles[variant];
|
||||
const sizeClass = sizeStyles[size];
|
||||
|
||||
// Selected state for secondary variant
|
||||
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
|
||||
|
||||
// Background color for primary variant
|
||||
const backgroundColor = variant === "primary" ? COLORS.primary : undefined;
|
||||
|
||||
// Combine all classes
|
||||
const combinedClasses = `
|
||||
${baseClasses}
|
||||
${variantClass}
|
||||
${sizeClass}
|
||||
${selectedClass}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${className}
|
||||
`.trim().replace(/\s+/g, " ");
|
||||
|
||||
// Icon rendering helper for SVG icons
|
||||
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
|
||||
if (!icon) return null;
|
||||
return (
|
||||
<span className={`flex items-center ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Image icon rendering for Next.js Image
|
||||
const renderImageIcon = () => {
|
||||
if (!iconSrc) return null;
|
||||
const sizeMap = { sm: 16, md: 20, lg: 24 };
|
||||
const imgSize = sizeMap[size] || 20;
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={iconSrc}
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
alt={iconAlt || "icon"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Content assembly
|
||||
const content = (
|
||||
<>
|
||||
{renderImageIcon()}
|
||||
{renderSvgIcon(leftIcon, "left")}
|
||||
{children}
|
||||
{renderSvgIcon(rightIcon, "right")}
|
||||
</>
|
||||
);
|
||||
|
||||
// If href is provided, render as Link
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={combinedClasses}
|
||||
style={{ ...style, backgroundColor }}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise render as button
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={combinedClasses}
|
||||
style={{ ...style, backgroundColor }}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/CardList.tsx
Normal file
30
src/components/ui/CardList.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CardList - 可滚动的卡片列表容器
|
||||
*
|
||||
* 用于显示可滚动的列表内容,如文件夹列表、文本对列表等
|
||||
* - 最大高度 96 (24rem)
|
||||
* - 垂直滚动
|
||||
* - 圆角边框
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CardList>
|
||||
* {items.map(item => (
|
||||
* <div key={item.id}>{item.name}</div>
|
||||
* ))}
|
||||
* </CardList>
|
||||
* ```
|
||||
*/
|
||||
interface CardListProps {
|
||||
children: React.ReactNode;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CardList({ children, className = "" }: CardListProps) {
|
||||
return (
|
||||
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/ui/LocaleSelector.tsx
Normal file
48
src/components/ui/LocaleSelector.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { LOCALES } from "@/config/locales";
|
||||
|
||||
const COMMON_LOCALES = [
|
||||
{ label: "中文", value: "zh-CN" },
|
||||
{ label: "英文", value: "en-US" },
|
||||
{ label: "意大利语", value: "it-IT" },
|
||||
{ label: "日语", value: "ja-JP" },
|
||||
{ label: "其他", value: "other" },
|
||||
];
|
||||
|
||||
interface LocaleSelectorProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}
|
||||
|
||||
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
|
||||
const showFullList = value === "other" || !isCommonLocale;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={isCommonLocale ? value : "other"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||
>
|
||||
{COMMON_LOCALES.map((locale) => (
|
||||
<option key={locale.value} value={locale.value}>
|
||||
{locale.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showFullList && (
|
||||
<select
|
||||
value={value === "other" ? LOCALES[0] : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
|
||||
>
|
||||
{LOCALES.map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/PageHeader.tsx
Normal file
29
src/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* PageHeader - 页面标题组件
|
||||
*
|
||||
* 用于 PageLayout 内的页面标题,支持主标题和可选的副标题
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageHeader title="我的文件夹" subtitle="管理和组织你的学习内容" />
|
||||
* ```
|
||||
*/
|
||||
interface PageHeaderProps {
|
||||
/** 页面主标题 */
|
||||
title: string;
|
||||
/** 可选的副标题/描述 */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/PageLayout.tsx
Normal file
33
src/components/ui/PageLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PageLayout - 统一的页面布局组件
|
||||
*
|
||||
* 提供应用统一的标准页面布局:
|
||||
* - 绿色背景 (#35786f)
|
||||
* - 居中的白色圆角卡片
|
||||
* - 响应式内边距
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageLayout>
|
||||
* <PageHeader title="标题" subtitle="副标题" />
|
||||
* <div>页面内容</div>
|
||||
* </PageLayout>
|
||||
* ```
|
||||
*/
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
/** 额外的 CSS 类名,用于自定义布局行为 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PageLayout({ children, className = "" }: PageLayoutProps) {
|
||||
return (
|
||||
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import PlainButton, { ButtonType } from "./PlainButton";
|
||||
|
||||
export default function DarkButton({
|
||||
onClick,
|
||||
className,
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
||||
|
||||
export default function GhostButton({
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
href
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded hover:bg-black/30 p-2 ${className}`}
|
||||
type={type}
|
||||
>
|
||||
{href ? <Link href={href}>{children}</Link> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
interface IconClickProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
size?: number;
|
||||
disableOnHoverBgChange?: boolean;
|
||||
}
|
||||
export default function IconClick({
|
||||
src,
|
||||
alt,
|
||||
onClick = () => {},
|
||||
className = "",
|
||||
size = 32,
|
||||
disableOnHoverBgChange = false,
|
||||
}: IconClickProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`${disableOnHoverBgChange ? "" : "hover:bg-gray-200"} hover:cursor-pointer rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}
|
||||
>
|
||||
<Image src={src} width={size - 5} height={size - 5} alt={alt}></Image>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import PlainButton, { ButtonType } from "../buttons/PlainButton";
|
||||
|
||||
export default function LightButton({
|
||||
onClick,
|
||||
className,
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
||||
|
||||
export default function PlainButton({
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
56
src/components/ui/buttons/index.tsx
Normal file
56
src/components/ui/buttons/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// 向后兼容的按钮组件包装器
|
||||
// 这些组件将新 Button 组件包装,以保持向后兼容
|
||||
|
||||
import Button from "../Button";
|
||||
|
||||
// LightButton: 次要按钮,支持 selected 状态
|
||||
export const LightButton = (props: any) => <Button variant="secondary" {...props} />;
|
||||
|
||||
// GreenButton: 主题色主要按钮
|
||||
export const GreenButton = (props: any) => <Button variant="primary" {...props} />;
|
||||
|
||||
// IconButton: SVG 图标按钮
|
||||
export const IconButton = (props: any) => {
|
||||
const { icon, ...rest } = props;
|
||||
return <Button variant="icon" leftIcon={icon} {...rest} />;
|
||||
};
|
||||
|
||||
// GhostButton: 透明导航按钮
|
||||
export const GhostButton = (props: any) => {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<Button variant="ghost" className={className} {...rest}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// IconClick: 图片图标按钮
|
||||
export const IconClick = (props: any) => {
|
||||
// IconClick 使用 src/alt 属性,需要映射到 Button 的 iconSrc/iconAlt
|
||||
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
|
||||
let buttonSize: "sm" | "md" | "lg" = "md";
|
||||
if (typeof size === "number") {
|
||||
if (size <= 20) buttonSize = "sm";
|
||||
else if (size >= 32) buttonSize = "lg";
|
||||
} else if (typeof size === "string") {
|
||||
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
|
||||
}
|
||||
|
||||
// 如果禁用悬停背景变化,通过 className 覆盖
|
||||
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30 hover:cursor-pointer border-0 bg-transparent shadow-none" : "";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSrc={src}
|
||||
iconAlt={alt}
|
||||
size={buttonSize}
|
||||
className={`${hoverClass} ${className || ""}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// PlainButton: 基础小按钮
|
||||
export const PlainButton = (props: any) => <Button variant="secondary" size="sm" {...props} />;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/interfaces";
|
||||
import z from "zod";
|
||||
import { shallowEqual } from "../utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
key: string,
|
||||
@@ -24,14 +25,14 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Invalid data structure in localStorage:",
|
||||
result.error,
|
||||
);
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${key} data:`, e);
|
||||
logger.error(`Failed to parse ${key} data:`, e);
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
},
|
||||
|
||||
29
src/lib/logger.ts
Normal file
29
src/lib/logger.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 统一的日志工具
|
||||
* 在生产环境中可以通过环境变量控制日志级别
|
||||
*/
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
export const logger = {
|
||||
error: (message: string, error?: unknown) => {
|
||||
if (isDevelopment) {
|
||||
console.error(message, error);
|
||||
}
|
||||
// 在生产环境中,这里可以发送到错误追踪服务(如 Sentry)
|
||||
},
|
||||
|
||||
warn: (message: string, data?: unknown) => {
|
||||
if (isDevelopment) {
|
||||
console.warn(message, data);
|
||||
}
|
||||
},
|
||||
|
||||
info: (message: string, data?: unknown) => {
|
||||
if (isDevelopment) {
|
||||
console.info(message, data);
|
||||
}
|
||||
},
|
||||
};
|
||||
14
src/lib/theme/colors.ts
Normal file
14
src/lib/theme/colors.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 主题配色常量
|
||||
* 集中管理应用的品牌颜色
|
||||
*
|
||||
* 注意:Tailwind CSS 已有的标准颜色(gray、red 等)请直接使用 Tailwind 类名
|
||||
* 这里只定义项目独有的品牌色
|
||||
*/
|
||||
export const COLORS = {
|
||||
// ===== 主色调 =====
|
||||
/** 主绿色 - 应用主题色,用于页面背景、主要按钮 */
|
||||
primary: '#35786f',
|
||||
/** 悬停绿色 - 按钮悬停状态 */
|
||||
primaryHover: '#2d5f58'
|
||||
} as const;
|
||||
Reference in New Issue
Block a user