... ... ... ...
This commit is contained in:
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} />;
|
||||
Reference in New Issue
Block a user