-
setShowLetter(!showLetter)}
- >
- {t("letter")}
-
- {/* IPA 音标显示切换 */}
-
setShowIPA(!showIPA)}
- >
- IPA
-
- {/* 罗马音显示切换(仅日语显示) */}
- {hasRomanization && (
-
setShowRoman(!showRoman)}
- >
- {t("roman")}
-
- )}
- {/* 随机模式切换 */}
-
setIsRandomMode(!isRandomMode)}
- >
- {t("random")}
-
+ {/* 字母主要内容显示区域 */}
+
+ {/* 字母本身(可隐藏) */}
+ {showLetter ? (
+
+ {currentLetter.letter}
-
-
- {/* 字母主要内容显示区域 */}
-
- {/* 字母本身(可隐藏) */}
- {showLetter ? (
-
- {currentLetter.letter}
-
- ) : (
-
- ?
-
- )}
-
- {/* IPA 音标显示 */}
- {showIPA && (
-
- {currentLetter.letter_sound_ipa}
-
- )}
-
- {/* 罗马音显示(日语) */}
- {showRoman && hasRomanization && currentLetter.roman_letter && (
-
- {currentLetter.roman_letter}
-
- )}
-
-
- {/* 底部导航控制区域 */}
-
- {/* 上一个按钮 */}
-
-
-
-
- {/* 中间区域:随机按钮 */}
-
- {isRandomMode && (
-
- )}
+ ) : (
+
+ ?
+ )}
- {/* 下一个按钮 */}
-
-
-
+ {/* IPA 音标显示 */}
+ {showIPA && (
+
+ {currentLetter.letter_sound_ipa}
+
+ )}
+
+ {/* 罗马音显示(日语) */}
+ {showRoman && hasRomanization && currentLetter.roman_letter && (
+
+ {currentLetter.roman_letter}
+
+ )}
+
+
+ {/* 底部导航控制区域 */}
+
+ {/* 上一个按钮 */}
+
+
+
+
+ {/* 中间区域:随机按钮 */}
+
+ {isRandomMode && (
+
+ )}
-
- {/* 底部操作提示文字 */}
-
-
- {isRandomMode
- ? "使用左右箭头键或空格键随机切换字母,ESC键返回"
- : "使用左右箭头键或滑动切换字母,ESC键返回"
- }
-
+ {/* 下一个按钮 */}
+
+
+
+
+
+ {/* 底部操作提示文字 */}
+
+
+ {isRandomMode
+ ? "使用左右箭头键或空格键随机切换字母,ESC键返回"
+ : "使用左右箭头键或滑动切换字母,ESC键返回"
+ }
+
{/* 全屏触摸事件监听层(用于滑动切换) */}
@@ -224,6 +224,6 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
-
+
);
}
\ No newline at end of file
diff --git a/src/app/(features)/alphabet/page.tsx b/src/app/(features)/alphabet/page.tsx
index e437d7a..4e594e0 100644
--- a/src/app/(features)/alphabet/page.tsx
+++ b/src/app/(features)/alphabet/page.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
-import { Container } from "@/components/ui/Container";
+import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/components/ui/buttons";
import { AlphabetCard } from "./AlphabetCard";
@@ -48,87 +48,81 @@ export default function Alphabet() {
// 语言选择界面
if (!chosenAlphabet) {
return (
-
-
- {/* 页面标题 */}
-
- {t("chooseCharacters")}
-
- {/* 副标题说明 */}
-
- 选择一种语言的字母表开始学习
-
+
+ {/* 页面标题 */}
+
+ {t("chooseCharacters")}
+
+ {/* 副标题说明 */}
+
+ 选择一种语言的字母表开始学习
+
- {/* 语言选择按钮网格 */}
-
- {/* 日语假名选项 */}
-
setChosenAlphabet("japanese")}
- className="p-6 text-lg font-medium hover:scale-105 transition-transform"
- >
-
- あいうえお
- {t("japanese")}
-
-
+ {/* 语言选择按钮网格 */}
+
+ {/* 日语假名选项 */}
+
setChosenAlphabet("japanese")}
+ className="p-6 text-lg font-medium hover:scale-105 transition-transform"
+ >
+
+ あいうえお
+ {t("japanese")}
+
+
- {/* 英语字母选项 */}
-
setChosenAlphabet("english")}
- className="p-6 text-lg font-medium hover:scale-105 transition-transform"
- >
-
- ABC
- {t("english")}
-
-
+ {/* 英语字母选项 */}
+
setChosenAlphabet("english")}
+ className="p-6 text-lg font-medium hover:scale-105 transition-transform"
+ >
+
+ ABC
+ {t("english")}
+
+
- {/* 维吾尔语字母选项 */}
-
setChosenAlphabet("uyghur")}
- className="p-6 text-lg font-medium hover:scale-105 transition-transform"
- >
-
- ئۇيغۇر
- {t("uyghur")}
-
-
+ {/* 维吾尔语字母选项 */}
+
setChosenAlphabet("uyghur")}
+ className="p-6 text-lg font-medium hover:scale-105 transition-transform"
+ >
+
+ ئۇيغۇر
+ {t("uyghur")}
+
+
- {/* 世界语字母选项 */}
-
setChosenAlphabet("esperanto")}
- className="p-6 text-lg font-medium hover:scale-105 transition-transform"
- >
-
- ABCĜĤ
- {t("esperanto")}
-
-
-
-
-
+ {/* 世界语字母选项 */}
+ setChosenAlphabet("esperanto")}
+ className="p-6 text-lg font-medium hover:scale-105 transition-transform"
+ >
+
+ ABCĜĤ
+ {t("esperanto")}
+
+
+
+
);
}
// 加载状态
if (loadingState === "loading") {
return (
-
+
+ {t("loading")}
+
);
}
// 错误状态
if (loadingState === "error") {
return (
-
-
- {t("loadFailed")}
-
-
+
+ {t("loadFailed")}
+
);
}
diff --git a/src/app/(features)/dictionary/page.tsx b/src/app/(features)/dictionary/page.tsx
index 4198358..1b1df0e 100644
--- a/src/app/(features)/dictionary/page.tsx
+++ b/src/app/(features)/dictionary/page.tsx
@@ -1,4 +1,4 @@
-import { Container } from "@/components/ui/Container";
+import { PageLayout } from "@/components/ui/PageLayout";
import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult";
import { getTranslations } from "next-intl/server";
@@ -43,37 +43,33 @@ export default async function DictionaryPage({ searchParams }: DictionaryPagePro
}
return (
-
+
{/* 搜索区域 */}
-
-
-
-
+
+
{/* 搜索结果区域 */}
-
-
- {searchQuery && (
-
- )}
- {!searchQuery && (
-
-
📚
-
{t("welcomeTitle")}
-
{t("welcomeHint")}
-
- )}
-
+
+ {searchQuery && (
+
+ )}
+ {!searchQuery && (
+
+
📚
+
{t("welcomeTitle")}
+
{t("welcomeHint")}
+
+ )}
-
+
);
}
diff --git a/src/app/(features)/memorize/FolderSelector.tsx b/src/app/(features)/memorize/FolderSelector.tsx
index ebaddd0..64cef99 100644
--- a/src/app/(features)/memorize/FolderSelector.tsx
+++ b/src/app/(features)/memorize/FolderSelector.tsx
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
+import { PageLayout } from "@/components/ui/PageLayout";
interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[];
@@ -15,81 +16,77 @@ const FolderSelector: React.FC
= ({ folders }) => {
const router = useRouter();
return (
-
-
-
- {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,
- })}
-
-
- {/* 右箭头 */}
-
-
- ))}
-
- >
- )}
+
+ {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 286f25c..e6bb377 100644
--- a/src/app/(features)/memorize/Memorize.tsx
+++ b/src/app/(features)/memorize/Memorize.tsx
@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { TSharedPair } from "@/shared/folder-type";
+import { PageLayout } from "@/components/ui/PageLayout";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
@@ -28,11 +29,9 @@ const Memorize: React.FC
= ({ textPairs }) => {
if (textPairs.length === 0) {
return (
-
+
+ {t("noTextPairs")}
+
);
}
@@ -113,87 +112,83 @@ const Memorize: React.FC = ({ textPairs }) => {
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
return (
-
-
-
- {/* 进度指示器 */}
-
-
- {index + 1} / {getTextPairs().length}
-
-
-
- {/* 文本显示区域 */}
-
- {(() => {
- if (dictation) {
- if (show === "question") {
- return (
-
- );
- } else {
- return (
-
- {createText(text1)}
-
- {createText(text2)}
-
- );
- }
- } else {
- if (show === "question") {
- return createText(text1);
- } else {
- return (
-
- {createText(text1)}
-
- {createText(text2)}
-
- );
- }
- }
- })()}
-
-
- {/* 底部按钮 */}
-
-
- {show === "question" ? t("answer") : t("next")}
-
-
- {t("previous")}
-
-
- {t("reverse")}
-
-
- {t("dictation")}
-
-
- {t("disorder")}
-
-
-
+
+ {/* 进度指示器 */}
+
+
+ {index + 1} / {getTextPairs().length}
+
-
+
+ {/* 文本显示区域 */}
+
+ {(() => {
+ if (dictation) {
+ if (show === "question") {
+ return (
+
+ );
+ } else {
+ return (
+
+ {createText(text1)}
+
+ {createText(text2)}
+
+ );
+ }
+ } else {
+ if (show === "question") {
+ return createText(text1);
+ } else {
+ return (
+
+ {createText(text1)}
+
+ {createText(text2)}
+
+ );
+ }
+ }
+ })()}
+
+
+ {/* 底部按钮 */}
+
+
+ {show === "question" ? t("answer") : t("next")}
+
+
+ {t("previous")}
+
+
+ {t("reverse")}
+
+
+ {t("dictation")}
+
+
+ {t("disorder")}
+
+
+
);
};
diff --git a/src/app/(features)/srt-player/page.tsx b/src/app/(features)/srt-player/page.tsx
index ef8afed..c245655 100644
--- a/src/app/(features)/srt-player/page.tsx
+++ b/src/app/(features)/srt-player/page.tsx
@@ -4,6 +4,7 @@ import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Video, FileText } from "lucide-react";
+import { PageLayout } from "@/components/ui/PageLayout";
import { useSrtPlayer } from "./hooks/useSrtPlayer";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
@@ -106,23 +107,19 @@ export default function SrtPlayerPage() {
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
return (
-
-
-
- {/* 标题区域 */}
-
-
- {t("srtPlayer.name")}
-
-
- {t("srtPlayer.description")}
-
-
+
+ {/* 标题区域 */}
+
+
+ {t("srtPlayer.name")}
+
+
+ {t("srtPlayer.description")}
+
+
- {/* 主要内容区域 */}
-
- {/* 视频播放器区域 */}
-
+ {/* 视频播放器区域 */}
+
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
@@ -163,10 +160,10 @@ export default function SrtPlayerPage() {
)}
- {/* 控制面板 */}
-
-
+
);
}
\ No newline at end of file
diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx
index 20f9229..527b9f0 100644
--- a/src/app/(features)/translator/page.tsx
+++ b/src/app/(features)/translator/page.tsx
@@ -95,7 +95,7 @@ export default function TranslatorPage() {
};
return (
- <>
+
{/* TCard Component */}
{/* Card Component - Left Side */}
@@ -218,6 +218,6 @@ export default function TranslatorPage() {
{t("translate")}
- >
+
);
}
diff --git a/src/app/auth/AuthForm.tsx b/src/app/auth/AuthForm.tsx
index 209633d..2ac3a7c 100644
--- a/src/app/auth/AuthForm.tsx
+++ b/src/app/auth/AuthForm.tsx
@@ -2,7 +2,7 @@
import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl";
-import { Container } from "@/components/ui/Container";
+import { PageLayout } from "@/components/ui/PageLayout";
import { Input } from "@/components/ui/Input";
import { LightButton, LinkButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client";
@@ -125,8 +125,7 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
const currentError = mode === 'signin' ? signInState : signUpState;
return (
-
-
+
{/* 页面标题 */}
{t(mode === 'signin' ? 'signIn' : 'signUp')}
@@ -281,7 +280,6 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
}
-
-
+
);
}
diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx
index 943f929..b5a27b3 100644
--- a/src/app/users/[username]/page.tsx
+++ b/src/app/users/[username]/page.tsx
@@ -1,6 +1,6 @@
import Image from "next/image";
import Link from "next/link";
-import { Container } from "@/components/ui/Container";
+import { PageLayout } from "@/components/ui/PageLayout";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { notFound } from "next/navigation";
@@ -36,10 +36,9 @@ export default async function UserPage({ params }: UserPageProps) {
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
return (
-
-
- {/* Header */}
-
+
+ {/* Header */}
+
{isOwnProfile &&
}
@@ -191,7 +190,6 @@ export default async function UserPage({ params }: UserPageProps) {
)}
-
-
+
);
}
diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx
new file mode 100644
index 0000000..e549b3c
--- /dev/null
+++ b/src/components/ui/Card.tsx
@@ -0,0 +1,73 @@
+/**
+ * Card - 可复用的卡片组件
+ *
+ * 提供应用统一的标准白色卡片样式:
+ * - 白色背景
+ * - 圆角 (rounded-2xl)
+ * - 阴影 (shadow-xl)
+ * - 可配置内边距
+ * - 多种样式变体
+ *
+ * @example
+ * ```tsx
+ * // 默认卡片
+ *
+ * 卡片内容
+ *
+ *
+ * // 带边框的卡片
+ *
+ * 带边框的内容
+ *
+ *
+ * // 无内边距卡片
+ *
+ *
+ *
+ * ```
+ */
+export type CardVariant = "default" | "bordered" | "elevated";
+export type CardPadding = "none" | "sm" | "md" | "lg" | "xl";
+
+export interface CardProps {
+ children: React.ReactNode;
+ /** 额外的 CSS 类名,用于自定义样式 */
+ className?: string;
+ /** 卡片样式变体 */
+ variant?: CardVariant;
+ /** 内边距大小 */
+ padding?: CardPadding;
+}
+
+// 变体样式映射
+const variantClasses: Record = {
+ default: "bg-white shadow-xl",
+ bordered: "bg-white border-2 border-gray-200",
+ elevated: "bg-white shadow-2xl",
+};
+
+// 内边距映射
+const paddingClasses: Record = {
+ none: "",
+ sm: "p-4",
+ md: "p-6",
+ lg: "p-8",
+ xl: "p-8 md:p-12",
+};
+
+export function Card({
+ children,
+ className = "",
+ variant = "default",
+ padding = "md",
+}: CardProps) {
+ const baseClasses = "rounded-2xl";
+ const variantClass = variantClasses[variant];
+ const paddingClass = paddingClasses[padding];
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/PageLayout.tsx b/src/components/ui/PageLayout.tsx
index 5babdfa..27b9ba7 100644
--- a/src/components/ui/PageLayout.tsx
+++ b/src/components/ui/PageLayout.tsx
@@ -3,31 +3,103 @@
*
* 提供应用统一的标准页面布局:
* - 绿色背景 (#35786f)
- * - 居中的白色圆角卡片
- * - 响应式内边距
+ * - 最小高度 min-h-[calc(100vh-64px)]
+ * - 支持多种布局变体
*
* @example
* ```tsx
+ * // 默认:居中白色卡片布局
*
*
* 页面内容
*
+ *
+ * // 全宽布局(无白色卡片)
+ *
+ * 页面内容
+ *
+ *
+ * // 全屏布局(用于 translator 等)
+ *
+ * 全屏内容
+ *
* ```
*/
+import { Card } from "./Card";
+
+type PageLayoutVariant = "centered-card" | "full-width" | "fullscreen";
+type MaxWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full";
+type AlignItems = "center" | "start" | "end";
+
interface PageLayoutProps {
children: React.ReactNode;
/** 额外的 CSS 类名,用于自定义布局行为 */
className?: string;
+ /** 布局变体 */
+ variant?: PageLayoutVariant;
+ /** 最大宽度(仅对 full-width 变体有效) */
+ maxWidth?: MaxWidth;
+ /** 内容垂直对齐方式(仅对 centered-card 变体有效) */
+ align?: AlignItems;
}
-export function PageLayout({ children, className = "" }: PageLayoutProps) {
- return (
-
-
-
+// 最大宽度映射
+const maxWidthClasses: Record
= {
+ sm: "max-w-sm",
+ md: "max-w-md",
+ lg: "max-w-lg",
+ xl: "max-w-xl",
+ "2xl": "max-w-2xl",
+ "3xl": "max-w-3xl",
+ full: "max-w-full",
+};
+
+// 对齐方式映射
+const alignClasses: Record = {
+ center: "items-center",
+ start: "items-start",
+ end: "items-end",
+};
+
+export function PageLayout({
+ children,
+ className = "",
+ variant = "centered-card",
+ maxWidth = "2xl",
+ align = "center",
+}: PageLayoutProps) {
+ // 默认变体:居中白色卡片布局
+ if (variant === "centered-card") {
+ return (
+
+ );
+ }
+
+ // 全宽布局:绿色背景,最大宽度容器,无白色卡片
+ if (variant === "full-width") {
+ return (
+
-
- );
+ );
+ }
+
+ // 全屏布局:仅绿色背景,无其他限制
+ if (variant === "fullscreen") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return null;
}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 1dbd005..0def8d7 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -31,6 +31,8 @@ export { Container } from './Container';
export { PageLayout } from './PageLayout';
export { PageHeader } from './PageHeader';
export { CardList } from './CardList';
+export { Card } from './Card';
+export type { CardProps, CardVariant, CardPadding } from './Card';
// 复合组件
export { LocaleSelector } from './LocaleSelector';