From 12eb5c412aca64e79806b09a40f500b9f10ddb22 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Fri, 6 Feb 2026 04:36:06 +0800 Subject: [PATCH] layout --- src/app/(features)/alphabet/AlphabetCard.tsx | 222 +++++++++--------- src/app/(features)/alphabet/page.tsx | 126 +++++----- src/app/(features)/dictionary/page.tsx | 52 ++-- .../(features)/memorize/FolderSelector.tsx | 145 ++++++------ src/app/(features)/memorize/Memorize.tsx | 165 +++++++------ src/app/(features)/srt-player/page.tsx | 42 ++-- src/app/(features)/translator/page.tsx | 4 +- src/app/auth/AuthForm.tsx | 8 +- src/app/users/[username]/page.tsx | 12 +- src/components/ui/Card.tsx | 73 ++++++ src/components/ui/PageLayout.tsx | 90 ++++++- src/components/ui/index.ts | 2 + 12 files changed, 530 insertions(+), 411 deletions(-) create mode 100644 src/components/ui/Card.tsx diff --git a/src/app/(features)/alphabet/AlphabetCard.tsx b/src/app/(features)/alphabet/AlphabetCard.tsx index a7fdb19..e7421ca 100644 --- a/src/app/(features)/alphabet/AlphabetCard.tsx +++ b/src/app/(features)/alphabet/AlphabetCard.tsx @@ -6,6 +6,8 @@ import { Letter, SupportedAlphabets } from "@/lib/interfaces"; import { IconClick, CircleToggleButton, CircleButton } from "@/components/ui/buttons"; import { IMAGES } from "@/config/images"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { PageLayout } from "@/components/ui/PageLayout"; +import { Card } from "@/components/ui/Card"; interface AlphabetCardProps { alphabet: Letter[]; @@ -97,124 +99,122 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro }; return ( -
-
- {/* 右上角返回按钮 */} -
- + + {/* 右上角返回按钮 - outside the white card */} +
+ +
+ + {/* 白色主卡片容器 */} + + {/* 顶部进度指示器和显示选项按钮 */} +
+ {/* 当前字母进度 */} + + {currentIndex + 1} / {alphabet.length} + + {/* 显示选项切换按钮组 */} +
+ setShowLetter(!showLetter)} + > + {t("letter")} + + {/* IPA 音标显示切换 */} + setShowIPA(!showIPA)} + > + IPA + + {/* 罗马音显示切换(仅日语显示) */} + {hasRomanization && ( + setShowRoman(!showRoman)} + > + {t("roman")} + + )} + {/* 随机模式切换 */} + setIsRandomMode(!isRandomMode)} + > + {t("random")} + +
- {/* 白色主卡片容器 */} -
- {/* 顶部进度指示器和显示选项按钮 */} -
- {/* 当前字母进度 */} - - {currentIndex + 1} / {alphabet.length} - - {/* 显示选项切换按钮组 */} -
- 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")}
-
-
+ +
{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")}

-
-
+ +

{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 ( +
+
+ + {children} + +
+
+ ); + } + + // 全宽布局:绿色背景,最大宽度容器,无白色卡片 + if (variant === "full-width") { + return ( +
+
{children}
-
- ); + ); + } + + // 全屏布局:仅绿色背景,无其他限制 + 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';