From 5f249291165fe4292a0451d7ec7201e67cd71948 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Mon, 29 Dec 2025 10:06:16 +0800 Subject: [PATCH] ... ... ... ... ... --- messages/en-US.json | 40 +-- messages/zh-CN.json | 47 +--- src/app/(features)/alphabet/AlphabetCard.tsx | 34 ++- src/app/(features)/alphabet/MemoryCard.tsx | 4 +- src/app/(features)/alphabet/page.tsx | 17 +- .../(features)/memorize/FolderSelector.tsx | 118 +++++--- src/app/(features)/memorize/Memorize.tsx | 252 ++++++++++-------- .../srt-player/VideoPlayer/VideoPanel.tsx | 2 +- .../components/atoms/PlayButton.tsx | 2 +- .../components/atoms/SpeedControl.tsx | 2 +- .../components/compounds/ControlBar.tsx | 18 +- .../components/compounds/UploadZone.tsx | 10 +- src/app/(features)/srt-player/page.tsx | 10 +- .../srt-player/utils/subtitleParser.ts | 3 +- src/app/(features)/text-speaker/SaveList.tsx | 2 +- src/app/(features)/text-speaker/page.tsx | 47 ++-- src/app/(features)/translator/AddToFolder.tsx | 2 +- .../(features)/translator/FolderSelector.tsx | 2 +- src/app/(features)/translator/page.tsx | 7 +- src/app/auth/AuthForm.tsx | 39 ++- src/app/folders/FoldersClient.tsx | 90 ++++--- .../folders/[folder_id]/AddTextPairModal.tsx | 30 +-- src/app/folders/[folder_id]/InFolder.tsx | 154 ++++++----- .../[folder_id]/UpdateTextPairModal.tsx | 31 +-- src/app/profile/LogoutButton.tsx | 2 +- src/app/profile/page.tsx | 39 +-- src/components/LanguageSettings.tsx | 4 +- src/components/layout/Navbar.tsx | 56 ++-- src/components/ui/Button.tsx | 163 +++++++++++ src/components/ui/CardList.tsx | 30 +++ src/components/ui/LocaleSelector.tsx | 48 ++++ src/components/ui/PageHeader.tsx | 29 ++ src/components/ui/PageLayout.tsx | 33 +++ src/components/ui/buttons/DarkButton.tsx | 28 -- src/components/ui/buttons/GhostButton.tsx | 27 -- src/components/ui/buttons/IconClick.tsx | 29 -- src/components/ui/buttons/LightButton.tsx | 28 -- src/components/ui/buttons/PlainButton.tsx | 26 -- src/components/ui/buttons/index.tsx | 56 ++++ src/lib/browser/localStorageOperators.ts | 5 +- src/lib/logger.ts | 29 ++ src/lib/theme/colors.ts | 14 + 42 files changed, 963 insertions(+), 646 deletions(-) create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/CardList.tsx create mode 100644 src/components/ui/LocaleSelector.tsx create mode 100644 src/components/ui/PageHeader.tsx create mode 100644 src/components/ui/PageLayout.tsx delete mode 100644 src/components/ui/buttons/DarkButton.tsx delete mode 100644 src/components/ui/buttons/GhostButton.tsx delete mode 100644 src/components/ui/buttons/IconClick.tsx delete mode 100644 src/components/ui/buttons/LightButton.tsx delete mode 100644 src/components/ui/buttons/PlainButton.tsx create mode 100644 src/components/ui/buttons/index.tsx create mode 100644 src/lib/logger.ts create mode 100644 src/lib/theme/colors.ts diff --git a/messages/en-US.json b/messages/en-US.json index a92aeb7..cf51ea8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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,18 +136,6 @@ "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", @@ -177,16 +150,7 @@ "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", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 95bf56c..e7d12ec 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -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,18 +137,6 @@ "next": "下句", "restart": "句首", "autoPause": "自动暂停({enabled})", - "playbackSpeed": "播放速度", - "subtitleSettings": "字幕设置", - "fontSize": "字体大小", - "backgroundColor": "背景颜色", - "textColor": "文字颜色", - "fontFamily": "字体", - "opacity": "透明度", - "position": "位置", - "top": "顶部", - "center": "居中", - "bottom": "底部", - "keyboardShortcuts": "键盘快捷键", "uploadVideoAndSubtitle": "请上传视频和字幕文件", "uploadVideoFile": "请上传视频文件", "uploadSubtitleFile": "请上传字幕文件", @@ -180,16 +150,7 @@ "on": "开", "off": "关", "videoUploadFailed": "视频上传失败", - "subtitleUploadFailed": "字幕上传失败", - "subtitleLoadSuccess": "字幕文件加载成功", - "subtitleLoadFailed": "字幕文件加载失败", - "shortcuts": { - "playPause": "播放/暂停", - "next": "下一句", - "previous": "上一句", - "restart": "句首", - "autoPause": "切换自动暂停" - } + "subtitleUploadFailed": "字幕上传失败" }, "text_speaker": { "generateIPA": "生成IPA", diff --git a/src/app/(features)/alphabet/AlphabetCard.tsx b/src/app/(features)/alphabet/AlphabetCard.tsx index 637a120..38b4036 100644 --- a/src/app/(features)/alphabet/AlphabetCard.tsx +++ b/src/app/(features)/alphabet/AlphabetCard.tsx @@ -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 (
- {/* 返回按钮 */} + {/* 右上角返回按钮 */}
- {/* 主卡片 */} + {/* 白色主卡片容器 */}
- {/* 进度指示器 */} + {/* 顶部进度指示器和显示选项按钮 */}
+ {/* 当前字母进度 */} {currentIndex + 1} / {alphabet.length} + {/* 显示选项切换按钮组 */}
+ {/* IPA 音标显示切换 */} + {/* 罗马音显示切换(仅日语显示) */} {hasRomanization && ( )} + {/* 随机模式切换 */}
- {/* 字母显示区域 */} + {/* 字母主要内容显示区域 */}
+ {/* 字母本身(可隐藏) */} {showLetter ? (
{currentLetter.letter} @@ -174,13 +180,15 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe ?
)} - + + {/* IPA 音标显示 */} {showIPA && (
{currentLetter.letter_sound_ipa}
)} - + + {/* 罗马音显示(日语) */} {showRoman && hasRomanization && currentLetter.roman_letter && (
{currentLetter.roman_letter} @@ -188,8 +196,9 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe )}
- {/* 导航控制 */} + {/* 底部导航控制区域 */}
+ {/* 上一个按钮 */} + {/* 中间区域:随机按钮或进度条 */}
{isRandomMode ? ( + // 随机模式:显示随机切换按钮
- {/* 操作提示 */} + {/* 底部操作提示文字 */}

{isRandomMode @@ -246,7 +260,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe

- {/* 触摸事件处理 */} + {/* 全屏触摸事件监听层(用于滑动切换) */}
+ {/* 页面标题 */}

{t("chooseCharacters")}

+ {/* 副标题说明 */}

选择一种语言的字母表开始学习

- + + {/* 语言选择按钮网格 */}
+ {/* 日语假名选项 */} setChosenAlphabet("japanese")} className="p-6 text-lg font-medium hover:scale-105 transition-transform" @@ -67,7 +71,8 @@ export default function Alphabet() { {t("japanese")}
- + + {/* 英语字母选项 */} setChosenAlphabet("english")} className="p-6 text-lg font-medium hover:scale-105 transition-transform" @@ -77,7 +82,8 @@ export default function Alphabet() { {t("english")}
- + + {/* 维吾尔语字母选项 */} setChosenAlphabet("uyghur")} className="p-6 text-lg font-medium hover:scale-105 transition-transform" @@ -87,7 +93,8 @@ export default function Alphabet() { {t("uyghur")}
- + + {/* 世界语字母选项 */} setChosenAlphabet("esperanto")} className="p-6 text-lg font-medium hover:scale-105 transition-transform" diff --git a/src/app/(features)/memorize/FolderSelector.tsx b/src/app/(features)/memorize/FolderSelector.tsx index e519249..2a91d62 100644 --- a/src/app/(features)/memorize/FolderSelector.tsx +++ b/src/app/(features)/memorize/FolderSelector.tsx @@ -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 = ({ folders }) => { const t = useTranslations("memorize.folder_selector"); const router = useRouter(); + return ( -
- - {(folders.length === 0 && ( -

- {t("noFolders")} - - folders - -

- )) || ( - <> -

- {t("selectFolder")} -

-
- {folders - .toSorted((a, b) => a.id - b.id) - .map((folder) => ( -
- 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" - > - -
- - {t("folderInfo", { - id: folder.id, - name: folder.name, - count: folder.total, - })} - -
-
- ))} +
+
+
+ {folders.length === 0 ? ( + // 空状态 - 显示提示和跳转按钮 +
+

+ {t("noFolders")} +

+ + Go to Folders +
- - )} - -
+ ) : ( + <> + {/* 页面标题 */} +

+ {t("selectFolder")} +

+ {/* 文件夹列表 */} +
+ {folders + .toSorted((a, b) => a.id - b.id) + .map((folder) => ( +
+ 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" + > + {/* 文件夹图标 */} +
+ +
+ {/* 文件夹信息 */} +
+
+ {folder.name} +
+
+ {t("folderInfo", { + id: folder.id, + name: folder.name, + count: folder.total, + })} +
+
+ {/* 右箭头 */} +
+ + + +
+
+ ))} +
+ + )} +
+
+
); }; diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx index 3d112fc..4f13dbc 100644 --- a/src/app/(features)/memorize/Memorize.tsx +++ b/src/app/(features)/memorize/Memorize.tsx @@ -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 = ({ textPairs }) => { const { load, play } = useAudioPlayer(); if (textPairs.length === 0) { - return

{t("noTextPairs")}

; + return ( +
+
+

{t("noTextPairs")}

+
+
+ ); } const rng = new SeededRandom(textPairs[0].folderId); @@ -38,135 +43,160 @@ const Memorize: React.FC = ({ 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 ( +
+ {text} +
+ ); + }; + + const [text1, text2] = reverse + ? [getTextPairs()[index].text2, getTextPairs()[index].text1] + : [getTextPairs()[index].text1, getTextPairs()[index].text2]; + return ( - <> - {(getTextPairs().length > 0 && ( - <> -
-
{ - const newIndex = prompt("Input a index number.")?.trim(); - if ( - newIndex && - isNonNegativeInteger(newIndex) && - parseInt(newIndex) <= textPairs.length && - parseInt(newIndex) > 0 - ) { - setIndex(parseInt(newIndex) - 1); - } - }} +
+
+
+ {/* 进度指示器 */} +
+
-
- {(() => { - const createText = (text: string) => { + {index + 1} / {getTextPairs().length} + +
+ + {/* 文本显示区域 */} +
+ {(() => { + if (dictation) { + if (show === "question") { return ( -
- {text} +
+
?
); - }; - - 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 ( +
+ {createText(text1)} +
+ {createText(text2)} +
+ ); } - })()} -
+ } else { + if (show === "question") { + return createText(text1); + } else { + return ( +
+ {createText(text1)} +
+ {createText(text2)} +
+ ); + } + } + })()}
+ + {/* 底部按钮 */}
- { - 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"); - }} + + + + +
- - )) ||

{t("noTextPairs")}

} - +
+
+
); }; diff --git a/src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx b/src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx index 37e53e9..5c1e469 100644 --- a/src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx +++ b/src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx @@ -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"; diff --git a/src/app/(features)/srt-player/components/atoms/PlayButton.tsx b/src/app/(features)/srt-player/components/atoms/PlayButton.tsx index d3d7332..c9bb4d3 100644 --- a/src/app/(features)/srt-player/components/atoms/PlayButton.tsx +++ b/src/app/(features)/srt-player/components/atoms/PlayButton.tsx @@ -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) { diff --git a/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx b/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx index 0ef233e..36644f1 100644 --- a/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx +++ b/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx @@ -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"; diff --git a/src/app/(features)/srt-player/components/compounds/ControlBar.tsx b/src/app/(features)/srt-player/components/compounds/ControlBar.tsx index 25e5312..91f946d 100644 --- a/src/app/(features)/srt-player/components/compounds/ControlBar.tsx +++ b/src/app/(features)/srt-player/components/compounds/ControlBar.tsx @@ -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} /> - {t("previous")} - + - {t("next")} - + - {t("restart")} - + - {t("autoPause", { enabled: autoPause ? t("on") : t("off") })} - +
); } \ No newline at end of file diff --git a/src/app/(features)/srt-player/components/compounds/UploadZone.tsx b/src/app/(features)/srt-player/components/compounds/UploadZone.tsx index 9286cd7..7f7d9b7 100644 --- a/src/app/(features)/srt-player/components/compounds/UploadZone.tsx +++ b/src/app/(features)/srt-player/components/compounds/UploadZone.tsx @@ -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 (
- + - {t("uploadSubtitle")} - +
); } \ No newline at end of file diff --git a/src/app/(features)/srt-player/page.tsx b/src/app/(features)/srt-player/page.tsx index ba802ae..bc1add3 100644 --- a/src/app/(features)/srt-player/page.tsx +++ b/src/app/(features)/srt-player/page.tsx @@ -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() {

- {state.video.url ? srtT("uploaded") : srtT("upload")} - + @@ -206,13 +206,13 @@ export default function SrtPlayerPage() {

- {state.subtitle.url ? srtT("uploaded") : srtT("upload")} - + diff --git a/src/app/(features)/srt-player/utils/subtitleParser.ts b/src/app/(features)/srt-player/utils/subtitleParser.ts index e686185..6145e89 100644 --- a/src/app/(features)/srt-player/utils/subtitleParser.ts +++ b/src/app/(features)/srt-player/utils/subtitleParser.ts @@ -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 { const data = await response.text(); return parseSrt(data); } catch (error) { - console.error('Failed to load subtitle:', error); + logger.error('加载字幕失败', error); return []; } } \ No newline at end of file diff --git a/src/app/(features)/text-speaker/SaveList.tsx b/src/app/(features)/text-speaker/SaveList.tsx index 25d81e0..e67d065 100644 --- a/src/app/(features)/text-speaker/SaveList.tsx +++ b/src/app/(features)/text-speaker/SaveList.tsx @@ -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"; diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx index d6a9136..5a6dc46 100644 --- a/src/app/(features)/text-speaker/page.tsx +++ b/src/app/(features)/text-speaker/page.tsx @@ -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 ( - <> + + {/* 文本输入区域 */}
+ {/* 文本输入框 */} + {/* IPA 显示区域 */} {(ipa.length !== 0 && ( -
+
{ipa}
)) ||
} -
+ + {/* 控制按钮区域 */} +
+ {/* 速度调节面板 */} {showSpeedAdjust && ( -
+
)} + {/* 播放/暂停按钮 */} + {/* 自动暂停按钮 */} { @@ -299,6 +306,7 @@ export default function TextSpeakerPage() { src={autopause ? IMAGES.autoplay : IMAGES.autopause} alt="autoplayorpause" > + {/* 速度调节按钮 */} setShowSpeedAdjust(!showSpeedAdjust)} @@ -306,6 +314,7 @@ export default function TextSpeakerPage() { alt="speed" className={`${showSpeedAdjust ? "bg-gray-200" : ""}`} > + {/* 保存按钮 */} + {/* 功能开关按钮 */}
- - + {/* 保存列表 */} + {showSaveList && ( +
+ +
+ )} + ); } diff --git a/src/app/(features)/translator/AddToFolder.tsx b/src/app/(features)/translator/AddToFolder.tsx index bd47cfc..382f263 100644 --- a/src/app/(features)/translator/AddToFolder.tsx +++ b/src/app/(features)/translator/AddToFolder.tsx @@ -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"; diff --git a/src/app/(features)/translator/FolderSelector.tsx b/src/app/(features)/translator/FolderSelector.tsx index b7a6388..5bb696c 100644 --- a/src/app/(features)/translator/FolderSelector.tsx +++ b/src/app/(features)/translator/FolderSelector.tsx @@ -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 { diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index b257ecb..998a069 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -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(); diff --git a/src/app/auth/AuthForm.tsx b/src/app/auth/AuthForm.tsx index 3b61829..0e7f2a7 100644 --- a/src/app/auth/AuthForm.tsx +++ b/src/app/auth/AuthForm.tsx @@ -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 = {}; - + 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) => { 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 (
+ {/* 页面标题 */}

{t(mode === 'signin' ? 'signIn' : 'signUp')}

+ {/* 服务器端错误提示 */} {currentError?.message && (
{currentError.message}
)} + {/* 登录/注册表单 */}
+ {/* 用户名输入(仅注册模式显示) */} {mode === 'signup' && (
+ {/* 客户端验证错误 */} {errors.name && (

{errors.name}

)} + {/* 服务器端验证错误 */} {currentError?.errors?.username && (

{currentError.errors.username[0]}

)}
)} + {/* 邮箱输入 */}
+ {/* 密码输入 */}
+ {/* 确认密码输入(仅注册模式显示) */} {mode === 'signup' && (
)} - - {isSignInPending || isSignUpPending - ? t("loading") + {isSignInPending || isSignUpPending + ? t("loading") : t(mode === 'signin' ? 'signInButton' : 'signUpButton') } - + + {/* 第三方登录区域 */}
+ {/* 分隔线 */}
@@ -208,6 +219,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
+ {/* GitHub 登录按钮 */}
+ {/* 模式切换链接 */}
@@ -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 ( -
-
-
-

{t("title")}

-

{t("subtitle")}

-
+ + - + {/* 新建文件夹按钮 */} + -
+ {/* 文件夹列表 */} +
+ {folders.length === 0 ? ( + // 空状态
-
+

{t("noFoldersYet")}

) : ( + // 文件夹卡片列表
{folders .toSorted((a, b) => a.id - b.id) @@ -164,8 +168,8 @@ export default function FoldersClient({ userId }: { userId: string }) { ))}
)} -
+
-
+ ); } diff --git a/src/app/folders/[folder_id]/AddTextPairModal.tsx b/src/app/folders/[folder_id]/AddTextPairModal.tsx index 431b60f..0b8e867 100644 --- a/src/app/folders/[folder_id]/AddTextPairModal.tsx +++ b/src/app/folders/[folder_id]/AddTextPairModal.tsx @@ -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(null); const input2Ref = useRef(null); - const input3Ref = useRef(null); - const input4Ref = useRef(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 (
{t("locale1")} - +
{t("locale2")} - +
{t("add")} diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/folders/[folder_id]/InFolder.tsx index c13d257..a421772 100644 --- a/src/app/folders/[folder_id]/InFolder.tsx +++ b/src/app/folders/[folder_id]/InFolder.tsx @@ -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 ( -
- -
- + + {/* 顶部导航和标题栏 */} +
+ {/* 返回按钮 */} + -
-
-

- {t("textPairs")} -

-

- {t("itemsCount", { count: textPairs.length })} -

-
+ {/* 页面标题和操作按钮 */} +
+ {/* 标题区域 */} +
+

+ {t("textPairs")} +

+

+ {t("itemsCount", { count: textPairs.length })} +

+
-
- { - redirect(`/memorize?folder_id=${folderId}`); - }} - > - {t("memorize")} - - -
+ {/* 操作按钮区域 */} +
+ { + redirect(`/memorize?folder_id=${folderId}`); + }} + > + {t("memorize")} + + { + setAddModal(true); + }} + icon={} + />
+
-
- {loading ? ( -
-
-

{t("loadingTextPairs")}

-
- ) : textPairs.length === 0 ? ( -
-

{t("noTextPairs")}

-
- ) : ( -
- {textPairs - .toSorted((a, b) => a.id - b.id) - .map((textPair) => ( - { - deletePairById(textPair.id); - refreshTextPairs(); - }} - refreshTextPairs={refreshTextPairs} - /> - ))} -
- )} -
- + {/* 文本对列表 */} + + {loading ? ( + // 加载状态 +
+
+

{t("loadingTextPairs")}

+
+ ) : textPairs.length === 0 ? ( + // 空状态 +
+

{t("noTextPairs")}

+
+ ) : ( + // 文本对卡片列表 +
+ {textPairs + .toSorted((a, b) => a.id - b.id) + .map((textPair) => ( + { + deletePairById(textPair.id); + refreshTextPairs(); + }} + refreshTextPairs={refreshTextPairs} + /> + ))} +
+ )} +
+ + {/* 添加文本对模态框 */} setAddModal(false)} @@ -151,6 +157,6 @@ export default function InFolder({ folderId }: { folderId: number }) { refreshTextPairs(); }} /> -
+ ); } diff --git a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx b/src/app/folders/[folder_id]/UpdateTextPairModal.tsx index 533e18e..a002429 100644 --- a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx +++ b/src/app/folders/[folder_id]/UpdateTextPairModal.tsx @@ -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(null); const input2Ref = useRef(null); - const input3Ref = useRef(null); - const input4Ref = useRef(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({
{t("locale1")} - +
{t("locale2")} - +
{t("update")} diff --git a/src/app/profile/LogoutButton.tsx b/src/app/profile/LogoutButton.tsx index ad553b0..02511df 100644 --- a/src/app/profile/LogoutButton.tsx +++ b/src/app/profile/LogoutButton.tsx @@ -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"; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index cb74811..c7d4bc3 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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 ( -
- -

{t("myProfile")}

+ + + + {/* 用户信息区域 */} +
+ {/* 用户头像 */} {session.user.image && ( User Avatar + className="rounded-full" + /> )} -

{session.user.name}

-

{t("email", { email: session.user.email })}

+ + {/* 用户名和邮箱 */} +
+

+ {session.user.name} +

+

{t("email", { email: session.user.email })}

+
+ + {/* 登出按钮 */} - -
+
+ ); } diff --git a/src/components/LanguageSettings.tsx b/src/components/LanguageSettings.tsx index fb68c8a..0d4c259 100644 --- a/src/components/LanguageSettings.tsx +++ b/src/components/LanguageSettings.tsx @@ -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} >
{showLanguageMenu && ( diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 3f91b70..05781ca 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -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 ( -
- +
+ {t("title")} - - + + -
+
+ GitHub - - + {t("folders")} - - + + - { - (() => { - return session && - {t("profile")} - || {t("sign_in")}; - - })() - } {t("sourceCode")} + { + (() => { + return session && + <> + {t("profile")} + + + + + || <> + {t("sign_in")} + + + + ; + + })() + }
); diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..267f47a --- /dev/null +++ b/src/components/ui/Button.tsx @@ -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 = { + 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 = { + 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 ( + + {icon} + + ); + }; + + // 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 ( + {iconAlt + ); + }; + + // Content assembly + const content = ( + <> + {renderImageIcon()} + {renderSvgIcon(leftIcon, "left")} + {children} + {renderSvgIcon(rightIcon, "right")} + + ); + + // If href is provided, render as Link + if (href) { + return ( + + {content} + + ); + } + + // Otherwise render as button + return ( + + ); +} diff --git a/src/components/ui/CardList.tsx b/src/components/ui/CardList.tsx new file mode 100644 index 0000000..40b1e35 --- /dev/null +++ b/src/components/ui/CardList.tsx @@ -0,0 +1,30 @@ +/** + * CardList - 可滚动的卡片列表容器 + * + * 用于显示可滚动的列表内容,如文件夹列表、文本对列表等 + * - 最大高度 96 (24rem) + * - 垂直滚动 + * - 圆角边框 + * + * @example + * ```tsx + * + * {items.map(item => ( + *
{item.name}
+ * ))} + *
+ * ``` + */ +interface CardListProps { + children: React.ReactNode; + /** 额外的 CSS 类名 */ + className?: string; +} + +export default function CardList({ children, className = "" }: CardListProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/LocaleSelector.tsx b/src/components/ui/LocaleSelector.tsx new file mode 100644 index 0000000..3b13af3 --- /dev/null +++ b/src/components/ui/LocaleSelector.tsx @@ -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 ( +
+ + {showFullList && ( + + )} +
+ ); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..c9a05b6 --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,29 @@ +/** + * PageHeader - 页面标题组件 + * + * 用于 PageLayout 内的页面标题,支持主标题和可选的副标题 + * + * @example + * ```tsx + * + * ``` + */ +interface PageHeaderProps { + /** 页面主标题 */ + title: string; + /** 可选的副标题/描述 */ + subtitle?: string; +} + +export default function PageHeader({ title, subtitle }: PageHeaderProps) { + return ( +
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/src/components/ui/PageLayout.tsx b/src/components/ui/PageLayout.tsx new file mode 100644 index 0000000..aa18d47 --- /dev/null +++ b/src/components/ui/PageLayout.tsx @@ -0,0 +1,33 @@ +/** + * PageLayout - 统一的页面布局组件 + * + * 提供应用统一的标准页面布局: + * - 绿色背景 (#35786f) + * - 居中的白色圆角卡片 + * - 响应式内边距 + * + * @example + * ```tsx + * + * + *
页面内容
+ *
+ * ``` + */ +interface PageLayoutProps { + children: React.ReactNode; + /** 额外的 CSS 类名,用于自定义布局行为 */ + className?: string; +} + +export default function PageLayout({ children, className = "" }: PageLayoutProps) { + return ( +
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/components/ui/buttons/DarkButton.tsx b/src/components/ui/buttons/DarkButton.tsx deleted file mode 100644 index 163d01f..0000000 --- a/src/components/ui/buttons/DarkButton.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} diff --git a/src/components/ui/buttons/GhostButton.tsx b/src/components/ui/buttons/GhostButton.tsx deleted file mode 100644 index a6ef6b7..0000000 --- a/src/components/ui/buttons/GhostButton.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/components/ui/buttons/IconClick.tsx b/src/components/ui/buttons/IconClick.tsx deleted file mode 100644 index 1e21a14..0000000 --- a/src/components/ui/buttons/IconClick.tsx +++ /dev/null @@ -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 ( - <> -
- {alt} -
- - ); -} diff --git a/src/components/ui/buttons/LightButton.tsx b/src/components/ui/buttons/LightButton.tsx deleted file mode 100644 index 0e4de45..0000000 --- a/src/components/ui/buttons/LightButton.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} diff --git a/src/components/ui/buttons/PlainButton.tsx b/src/components/ui/buttons/PlainButton.tsx deleted file mode 100644 index 12042de..0000000 --- a/src/components/ui/buttons/PlainButton.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/components/ui/buttons/index.tsx b/src/components/ui/buttons/index.tsx new file mode 100644 index 0000000..fee0a4a --- /dev/null +++ b/src/components/ui/buttons/index.tsx @@ -0,0 +1,56 @@ +// 向后兼容的按钮组件包装器 +// 这些组件将新 Button 组件包装,以保持向后兼容 + +import Button from "../Button"; + +// LightButton: 次要按钮,支持 selected 状态 +export const LightButton = (props: any) => + ); +}; + +// 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 ( +