This commit is contained in:
2025-12-29 11:18:36 +08:00
parent 3ac17f66f2
commit d3e1cd9092
29 changed files with 335 additions and 236 deletions

View File

@@ -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";

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import Container from "@/components/ui/Container";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import AlphabetCard from "./AlphabetCard";
export default function Alphabet() {

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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";

View File

@@ -3,7 +3,7 @@
import React from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { ControlBarProps } from "../../types/controls";
import PlayButton from "../atoms/PlayButton";
import SpeedControl from "../atoms/SpeedControl";

View File

@@ -4,7 +4,7 @@ import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Video, FileText } from "lucide-react";
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import { FileUploadProps } from "../../types/controls";
import { useFileUpload } from "../../hooks/useFileUpload";

View File

@@ -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 LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
export default function SrtPlayerPage() {
const t = useTranslations("home");

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";

View File

@@ -5,7 +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 { LightButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client";
interface AuthFormProps {

View File

@@ -1,8 +1,9 @@
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { LOCALES } from "@/config/locales";
interface AddTextPairModalProps {
isOpen: boolean;
@@ -15,6 +16,53 @@ interface AddTextPairModalProps {
) => void;
}
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;
}
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>
);
}
export default function AddTextPairModal({
isOpen,
onClose,
@@ -23,23 +71,23 @@ export default function AddTextPairModal({
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const input3Ref = useRef<HTMLInputElement>(null);
const input4Ref = useRef<HTMLInputElement>(null);
const [locale1, setLocale1] = useState("en-US");
const [locale2, setLocale2] = useState("zh-CN");
if (!isOpen) return null;
const handleAdd = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!input3Ref.current?.value ||
!input4Ref.current?.value
!locale1 ||
!locale2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
@@ -55,6 +103,7 @@ export default function AddTextPairModal({
input2Ref.current.value = "";
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
@@ -83,19 +132,11 @@ export default function AddTextPairModal({
</div>
<div>
{t("locale1")}
<Input
ref={input3Ref}
className="w-full"
placeholder="en-US"
></Input>
<LocaleSelector value={locale1} onChange={setLocale1} />
</div>
<div>
{t("locale2")}
<Input
ref={input4Ref}
className="w-full"
placeholder="zh-CN"
></Input>
<LocaleSelector value={locale2} onChange={setLocale2} />
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>

View File

@@ -12,8 +12,8 @@ import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard";
import { useTranslations } from "next-intl";
import PageLayout from "@/components/ui/PageLayout";
import GreenButton from "@/components/ui/buttons/GreenButton";
import IconButton from "@/components/ui/buttons/IconButton";
import { GreenButton } from "@/components/ui/buttons";
import { IconButton } from "@/components/ui/buttons";
import CardList from "@/components/ui/CardList";
export interface TextPair {

View File

@@ -1,4 +1,4 @@
import LightButton from "@/components/ui/buttons/LightButton";
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";

View File

@@ -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";

View File

@@ -1,9 +1,8 @@
"use client";
import IMAGES from "@/config/images";
import IconClick from "./ui/buttons/IconClick";
import { IconClick, GhostButton } from "./ui/buttons";
import { useState } from "react";
import GhostButton from "./ui/buttons/GhostButton";
export default function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
@@ -21,6 +20,7 @@ export default function LanguageSettings() {
alt="language"
disableOnHoverBgChange={true}
onClick={handleLanguageClick}
size={40}
></IconClick>
<div className="relative">
{showLanguageMenu && (

View File

@@ -1,11 +1,11 @@
import Image from "next/image";
import IMAGES from "@/config/images";
import { Folder, Home } from "lucide-react";
import { Folder, Home, User } from "lucide-react";
import LanguageSettings from "../LanguageSettings";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import GhostButton from "../ui/buttons/GhostButton";
import { GhostButton } from "../ui/buttons";
export async function Navbar() {
const t = await getTranslations("navbar");
@@ -14,46 +14,56 @@ export async function Navbar() {
});
return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<GhostButton href="/" className="text-xl border-b hidden md:block">
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!">
{t("title")}
</GhostButton>
<GhostButton className="block md:hidden" href={"/"}>
<Home />
<GhostButton className="block! md:hidden!" href={"/"}>
<Home size={20} />
</GhostButton>
<div className="flex text-xl gap-0.5 justify-center items-center flex-wrap">
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap">
<LanguageSettings />
<GhostButton
className="md:hidden block"
className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2"
href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
src={IMAGES.github_mark_white}
alt="GitHub"
width={24}
height={24}
width={20}
height={20}
/>
</GhostButton>
<LanguageSettings />
<GhostButton href="/folders" className="md:block hidden">
<GhostButton href="/folders" className="md:block! hidden! border-0 bg-transparent hover:bg-black/30 shadow-none">
{t("folders")}
</GhostButton>
<GhostButton href="/folders" className="md:hidden block">
<Folder />
<GhostButton href="/folders" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<Folder size={20} />
</GhostButton>
{
(() => {
return session &&
<GhostButton href="/profile">{t("profile")}</GhostButton>
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
})()
}
<GhostButton
className="hidden md:block"
className="hidden! md:block! border-0 bg-transparent hover:bg-black/30 shadow-none"
href="https://github.com/GoddoNebianU/learn-languages"
>
{t("sourceCode")}
</GhostButton>
{
(() => {
return session &&
<>
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("profile")}</GhostButton>
<GhostButton href="/profile" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<User size={20} />
</GhostButton>
</>
|| <>
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("sign_in")}</GhostButton>
<GhostButton href="/auth" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<User size={20} />
</GhostButton>
</>;
})()
}
</div>
</div>
);

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,30 +0,0 @@
import PlainButton, { ButtonType } from "./PlainButton";
import { COLORS } from "@/lib/theme/colors";
interface GreenButtonProps {
onClick?: () => void;
children: React.ReactNode;
className?: string;
type?: ButtonType;
disabled?: boolean;
}
export default function GreenButton({
onClick,
children,
className = "",
type = "button",
disabled = false,
}: GreenButtonProps) {
return (
<PlainButton
onClick={onClick}
className={`px-4 py-2 rounded-full transition-colors text-sm font-medium text-white hover:bg-opacity-90 ${className}`}
style={{ backgroundColor: COLORS.primary }}
type={type}
disabled={disabled}
>
{children}
</PlainButton>
);
}

View File

@@ -1,28 +0,0 @@
import PlainButton, { ButtonType } from "./PlainButton";
interface IconButtonProps {
onClick?: () => void;
icon: React.ReactNode;
className?: string;
type?: ButtonType;
disabled?: boolean;
}
export default function IconButton({
onClick,
icon,
className = "",
type = "button",
disabled = false,
}: IconButtonProps) {
return (
<PlainButton
onClick={onClick}
className={`p-2 bg-gray-200 rounded-full hover:bg-gray-300 transition-colors ${className}`}
type={type}
disabled={disabled}
>
{icon}
</PlainButton>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -1,29 +0,0 @@
export type ButtonType = "button" | "submit" | "reset" | undefined;
export default function PlainButton({
onClick,
className,
children,
type = "button",
disabled,
style
}: {
onClick?: () => void;
className?: string;
children?: React.ReactNode;
type?: ButtonType;
disabled?: boolean;
style?: React.CSSProperties;
}) {
return (
<button
onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
type={type}
disabled={disabled}
style={style}
>
{children}
</button>
);
}

View 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} />;