...
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-12-11 21:36:45 +08:00
parent 65aacc1582
commit b69e168558
41 changed files with 763 additions and 189 deletions

View File

@@ -10,7 +10,11 @@
"hideLetter": "Hide Letter",
"showLetter": "Show Letter",
"hideIPA": "Hide IPA",
"showIPA": "Show IPA"
"showIPA": "Show IPA",
"roman": "Romanization",
"letter": "Letter",
"random": "Random Mode",
"randomNext": "Random Next"
},
"folders": {
"title": "Folders",
@@ -82,6 +86,30 @@
"loading": "Loading...",
"githubLogin": "GitHub Login"
},
"auth": {
"title": "Authentication",
"signIn": "Sign In",
"signUp": "Sign Up",
"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",
"confirmPasswordRequired": "Please confirm your password"
},
"memorize": {
"folder_selector": {
"selectFolder": "Select a folder",

View File

@@ -10,7 +10,11 @@
"hideLetter": "隐藏字母",
"showLetter": "显示字母",
"hideIPA": "隐藏IPA",
"showIPA": "显示IPA"
"showIPA": "显示IPA",
"roman": "罗马音",
"letter": "字母",
"random": "随机模式",
"randomNext": "随机下一个"
},
"folders": {
"title": "文件夹",
@@ -82,6 +86,30 @@
"loading": "加载中...",
"githubLogin": "GitHub登录"
},
"auth": {
"title": "登录",
"signIn": "登录",
"signUp": "注册",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"name": "用户名",
"signInButton": "登录",
"signUpButton": "注册",
"noAccount": "还没有账户?",
"hasAccount": "已有账户?",
"signInWithGitHub": "使用GitHub登录",
"signUpWithGitHub": "使用GitHub注册",
"invalidEmail": "请输入有效的邮箱地址",
"passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配",
"signInFailed": "登录失败,请检查您的邮箱和密码",
"signUpFailed": "注册失败,请稍后再试",
"nameRequired": "请输入用户名",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码"
},
"memorize": {
"choose": {
"back": "返回",

View File

@@ -0,0 +1,258 @@
"use client";
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 IMAGES from "@/config/images";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface AlphabetCardProps {
alphabet: Letter[];
alphabetType: SupportedAlphabets;
onBack: () => void;
}
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
const t = useTranslations("alphabet");
const [currentIndex, setCurrentIndex] = useState(0);
const [showIPA, setShowIPA] = useState(true);
const [showLetter, setShowLetter] = useState(true);
const [showRoman, setShowRoman] = useState(false);
const [isRandomMode, setIsRandomMode] = useState(false);
// 只有日语假名显示罗马音按钮
const hasRomanization = alphabetType === "japanese";
const currentLetter = alphabet[currentIndex];
const goToNext = useCallback(() => {
if (isRandomMode) {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
} else {
setCurrentIndex((prev) => (prev === alphabet.length - 1 ? 0 : prev + 1));
}
}, [alphabet.length, isRandomMode]);
const goToPrevious = useCallback(() => {
if (isRandomMode) {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
} else {
setCurrentIndex((prev) => (prev === 0 ? alphabet.length - 1 : prev - 1));
}
}, [alphabet.length, isRandomMode]);
const goToRandom = useCallback(() => {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
}, [alphabet.length]);
// 键盘快捷键支持
useEffect(() => {
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
goToPrevious();
} else if (e.key === "ArrowRight") {
goToNext();
} else if (e.key === " ") {
e.preventDefault();
goToRandom();
} else if (e.key === "Escape") {
onBack();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [goToPrevious, goToNext, goToRandom, onBack]);
// 触摸滑动支持
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const minSwipeDistance = 50;
const onTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
goToNext();
}
if (isRightSwipe) {
goToPrevious();
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
{/* 返回按钮 */}
<div className="flex justify-end mb-4">
<IconClick
size={32}
alt="close"
src={IMAGES.close}
onClick={onBack}
className="bg-white rounded-full shadow-md"
/>
</div>
{/* 主卡片 */}
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
{/* 进度指示器 */}
<div className="flex justify-between items-center mb-6">
<span className="text-sm text-gray-500">
{currentIndex + 1} / {alphabet.length}
</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setShowLetter(!showLetter)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showLetter
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("letter")}
</button>
<button
onClick={() => setShowIPA(!showIPA)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showIPA
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
IPA
</button>
{hasRomanization && (
<button
onClick={() => setShowRoman(!showRoman)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showRoman
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("roman")}
</button>
)}
<button
onClick={() => setIsRandomMode(!isRandomMode)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
isRandomMode
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("random")}
</button>
</div>
</div>
{/* 字母显示区域 */}
<div className="text-center mb-8">
{showLetter ? (
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
{currentLetter.letter}
</div>
) : (
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
<span className="text-2xl md:text-3xl text-gray-400">?</span>
</div>
)}
{showIPA && (
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
{currentLetter.letter_sound_ipa}
</div>
)}
{showRoman && hasRomanization && currentLetter.roman_letter && (
<div className="text-lg md:text-xl text-gray-500">
{currentLetter.roman_letter}
</div>
)}
</div>
{/* 导航控制 */}
<div className="flex justify-between items-center">
<button
onClick={goToPrevious}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="上一个字母"
>
<ChevronLeft size={24} />
</button>
<div className="flex gap-2 items-center">
{isRandomMode ? (
<button
onClick={goToRandom}
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
>
{t("randomNext")}
</button>
) : (
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
{alphabet.slice(0, 20).map((_, index) => (
<div
key={index}
className={`h-2 rounded-full transition-all ${
index === currentIndex
? "w-8 bg-[#35786f]"
: "w-2 bg-gray-300"
}`}
/>
))}
{alphabet.length > 20 && (
<div className="text-xs text-gray-500 flex items-center">...</div>
)}
</div>
)}
</div>
<button
onClick={goToNext}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="下一个字母"
>
<ChevronRight size={24} />
</button>
</div>
</div>
{/* 操作提示 */}
<div className="text-center mt-6 text-white text-sm">
<p>
{isRandomMode
? "使用左右箭头键或空格键随机切换字母ESC键返回"
: "使用左右箭头键或滑动切换字母ESC键返回"
}
</p>
</div>
</div>
{/* 触摸事件处理 */}
<div
className="absolute inset-0 pointer-events-none"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {
Dispatch,
KeyboardEvent,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
@@ -19,25 +20,26 @@ export default function MemoryCard({
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) {
const t = useTranslations("alphabet");
const [index, setIndex] = useState(
Math.floor(Math.random() * alphabet.length),
);
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true);
const refresh = useCallback(() => {
if (alphabet.length > 0) {
setIndex(Math.floor(Math.random() * alphabet.length));
}
}, [alphabet.length]);
useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === " ") refresh();
};
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
});
}, [refresh]);
const letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
};
const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
return (
<div
className="w-full flex justify-center items-center"

View File

@@ -1,48 +1,37 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard";
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 AlphabetCard from "./AlphabetCard";
export default function Alphabet() {
const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] =
useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null,
english: null,
esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState("loading");
const loadAlphabetData = async () => {
if (chosenAlphabet && !alphabetData) {
try {
setLoadingState("loading");
fetch("/alphabets/" + chosenAlphabet + ".json")
.then((res) => {
const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
if (!res.ok) throw new Error("Network response was not ok");
return res.json();
})
.then((obj) => {
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
const obj = await res.json();
setAlphabetData(obj as Letter[]);
setLoadingState("success");
})
.catch(() => {
} catch (error) {
setLoadingState("error");
});
}
}
}
};
loadAlphabetData();
}, [chosenAlphabet, alphabetData]);
useEffect(() => {
@@ -50,48 +39,106 @@ export default function Alphabet() {
const timer = setTimeout(() => {
setLoadingState("idle");
setChosenAlphabet(null);
setAlphabetData(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
if (!chosenAlphabet)
// 语言选择界面
if (!chosenAlphabet) {
return (
<>
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
<div className="flex gap-1 flex-wrap">
<LightButton onClick={() => setChosenAlphabet("japanese")}>
{t("japanese")}
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
<Container className="p-8 max-w-2xl w-full text-center">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("chooseCharacters")}
</h1>
<p className="text-gray-600 mb-8 text-lg">
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<LightButton
onClick={() => setChosenAlphabet("japanese")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2"></span>
<span>{t("japanese")}</span>
</div>
</LightButton>
<LightButton onClick={() => setChosenAlphabet("english")}>
{t("english")}
<LightButton
onClick={() => setChosenAlphabet("english")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABC</span>
<span>{t("english")}</span>
</div>
</LightButton>
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
{t("uyghur")}
<LightButton
onClick={() => setChosenAlphabet("uyghur")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ئۇيغۇر</span>
<span>{t("uyghur")}</span>
</div>
</LightButton>
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
{t("esperanto")}
<LightButton
onClick={() => setChosenAlphabet("esperanto")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABCĜĤ</span>
<span>{t("esperanto")}</span>
</div>
</LightButton>
</div>
</div>
</>
</Container>
</div>
);
}
// 加载状态
if (loadingState === "loading") {
return t("loading");
}
if (loadingState === "error") {
return t("loadFailed");
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}
></MemoryCard>
</>
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<Container className="p-8 text-center">
<div className="text-2xl text-gray-600">{t("loading")}</div>
</Container>
</div>
);
}
// 错误状态
if (loadingState === "error") {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<Container className="p-8 text-center">
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
</Container>
</div>
);
}
// 字母卡片界面
if (loadingState === "success" && alphabetData) {
return (
<AlphabetCard
alphabet={alphabetData}
alphabetType={chosenAlphabet}
onBack={() => {
setChosenAlphabet(null);
setAlphabetData(null);
setLoadingState("idle");
}}
/>
);
}
return null;
}

View File

@@ -1,8 +1,8 @@
"use client";
import Container from "@/components/cards/Container";
import Container from "@/components/ui/Container";
import { useRouter } from "next/navigation";
import { Center } from "@/components/Center";
import { Center } from "@/components/common/Center";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder } from "../../../../generated/prisma/browser";

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales";

View File

@@ -21,7 +21,7 @@ export default async function MemorizePage({
if (!session) {
redirect(
`/login?redirect=/memorize${(await searchParams).folder_id
`/auth?redirect=/memorize${(await searchParams).folder_id
? `?folder_id=${tParam}`
: ""
}`,

View File

@@ -1,4 +1,4 @@
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { useRef } from "react";
import { useTranslations } from "next-intl";

View File

@@ -1,6 +1,6 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";

View File

@@ -6,7 +6,7 @@ import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import IconClick from "@/components/IconClick";
import IconClick from "@/components/ui/buttons/IconClick";
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/buttons/LightButton";
import IconClick from "@/components/IconClick";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {

View File

@@ -1,7 +1,7 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import Container from "@/components/cards/Container";
import LightButton from "@/components/ui/buttons/LightButton";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { Dispatch, useEffect, useState } from "react";
import z from "zod";

View File

@@ -1,8 +1,8 @@
import Container from "@/components/cards/Container";
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/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {

View File

@@ -1,7 +1,7 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";

209
src/app/auth/AuthForm.tsx Normal file
View File

@@ -0,0 +1,209 @@
"use client";
import { useState, useActionState } from "react";
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 { authClient } from "@/lib/auth-client";
interface AuthFormProps {
redirectTo?: string;
}
export default function AuthForm({ redirectTo }: AuthFormProps) {
const t = useTranslations("auth");
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [signInState, signInActionForm, isSignInPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => signInAction(prevState || {}, formData),
undefined
);
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => signUpAction(prevState || {}, formData),
undefined
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (formData: FormData): boolean => {
const newErrors: Record<string, string> = {};
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const name = formData.get("name") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!email) {
newErrors.email = t("emailRequired");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("invalidEmail");
}
if (!password) {
newErrors.password = t("passwordRequired");
} else if (password.length < 8) {
newErrors.password = t("passwordTooShort");
}
if (mode === 'signup') {
if (!name) {
newErrors.name = t("nameRequired");
}
if (!confirmPassword) {
newErrors.confirmPassword = t("confirmPasswordRequired");
} else if (password !== confirmPassword) {
newErrors.confirmPassword = t("passwordsNotMatch");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (validateForm(formData)) {
if (redirectTo) {
formData.append("redirectTo", redirectTo);
}
if (mode === 'signin') {
await signInActionForm(formData);
} else {
await signUpActionForm(formData);
}
}
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: "github",
callbackURL: redirectTo || "/"
});
};
const currentError = mode === 'signin' ? signInState : signUpState;
return (
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<Container className="p-8 max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
</div>
{currentError?.message && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{currentError.message}
</div>
)}
<form onSubmit={handleFormSubmit} className="space-y-4">
{mode === 'signup' && (
<div>
<Input
type="text"
name="name"
placeholder={t("name")}
className="w-full px-3 py-2"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
)}
<div>
<Input
type="email"
name="email"
placeholder={t("email")}
className="w-full px-3 py-2"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<Input
type="password"
name="password"
placeholder={t("password")}
className="w-full px-3 py-2"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
</div>
{mode === 'signup' && (
<div>
<Input
type="password"
name="confirmPassword"
placeholder={t("confirmPassword")}
className="w-full px-3 py-2"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
)}
<DarkButton
type="submit"
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSignInPending || isSignUpPending
? t("loading")
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
}
</DarkButton>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
<LightButton
onClick={handleGitHubSignIn}
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setMode(mode === 'signin' ? 'signup' : 'signin');
setErrors({});
}}
className="text-[#35786f] hover:underline"
>
{mode === 'signin'
? `${t("noAccount")} ${t("signUp")}`
: `${t("hasAccount")} ${t("signIn")}`
}
</button>
</div>
</Container>
</div>
);
}

20
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import AuthForm from "./AuthForm";
export default async function AuthPage(
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
}
) {
const searchParams = await props.searchParams;
const redirectTo = searchParams.redirect as string | undefined;
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
redirect(redirectTo || '/');
}
return <AuthForm redirectTo={redirectTo} />;
}

View File

@@ -8,7 +8,7 @@ import {
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Center } from "@/components/Center";
import { Center } from "@/components/common/Center";
import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser";
import {

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/buttons/LightButton";
import Input from "@/components/Input";
import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";
import { useTranslations } from "next-intl";
@@ -83,13 +83,19 @@ export default function AddTextPairModal({
</div>
<div>
{t("locale1")}
<Input ref={input3Ref} className="w-full"
placeholder="en-US"></Input>
<Input
ref={input3Ref}
className="w-full"
placeholder="en-US"
></Input>
</div>
<div>
{t("locale2")}
<Input ref={input4Ref} className="w-full"
placeholder="zh-CN"></Input>
<Input
ref={input4Ref}
className="w-full"
placeholder="zh-CN"
></Input>
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>

View File

@@ -1,10 +1,10 @@
"use client";
import { ArrowLeft, Plus } from "lucide-react";
import { Center } from "@/components/Center";
import { Center } from "@/components/common/Center";
import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation";
import Container from "@/components/cards/Container";
import Container from "@/components/ui/Container";
import {
createPair,
deletePairById,
@@ -12,7 +12,7 @@ import {
} from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard";
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { useTranslations } from "next-intl";
export interface TextPair {

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/buttons/LightButton";
import Input from "@/components/Input";
import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models";

View File

@@ -16,7 +16,7 @@ export default async function FoldersPage({
if (!folder_id) {
redirect("/folders");
}
if (!session) redirect(`/login?redirect=/folders/${folder_id}`);
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}

View File

@@ -7,6 +7,6 @@ export default async function FoldersPage() {
const session = await auth.api.getSession(
{ headers: await headers() }
);
if (!session) redirect(`/signin?redirect=/folders`);
if (!session) redirect(`/auth?redirect=/folders`);
return <FoldersClient userId={session.user.id} />;
}

View File

@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/Navbar";
import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner";
export const viewport: Viewport = {

View File

@@ -1,6 +1,6 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -12,7 +12,7 @@ export default function LogoutButton() {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login?redirect=/profile");
router.push("/auth?redirect=/profile");
}
}
});

View File

@@ -1,6 +1,6 @@
import Image from "next/image";
import { Center } from "@/components/Center";
import Container from "@/components/cards/Container";
import { Center } from "@/components/common/Center";
import Container from "@/components/ui/Container";
import { auth } from "@/auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
@@ -13,7 +13,7 @@ export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/signin?redirect=/profile");
redirect("/auth?redirect=/profile");
}
console.log(JSON.stringify(session, null, 2));
@@ -22,7 +22,7 @@ export default async function ProfilePage() {
<Center>
<Container className="p-6">
<h1>{t("myProfile")}</h1>
{(session.user.image) && (
{session.user.image && (
<Image
width={64}
height={64}
@@ -37,4 +37,4 @@ export default async function ProfilePage() {
</Container>
</Center>
);
};
}

View File

@@ -1,23 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import { signInAction } from "@/lib/actions/auth";
import Link from "next/link";
export default function SignInPage() {
return (
<div>
<h1>Sign In</h1>
<form action={signInAction}>
<input type="email"
name="email"
placeholder="Email"
required />
<input type="password"
name="password"
placeholder="Password"
required />
<LightButton type="submit">Sign In</LightButton>
</form>
<Link href={"/signup"}>Do not have an account? Sign up!</Link>
</div>
);
}

View File

@@ -1,33 +0,0 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { signUpAction } from "@/lib/actions/auth";
import Link from "next/link";
export default function SignInPage() {
return (
<div>
<h1>Sign Up</h1>
<form action={signUpAction}>
<input type="text"
name="name"
placeholder="Name"
required
/>
<input type="email"
name="email"
placeholder="Email"
required
/>
<input type="password"
name="password"
placeholder="Password"
required
/>
<LightButton type="submit">Sign Up</LightButton>
</form>
<Link href={"/signin"}>Already have an account? Sign in!</Link>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import IMAGES from "@/config/images";
import IconClick from "./IconClick";
import IconClick from "./ui/buttons/IconClick";
import { useState } from "react";
import GhostButton from "./buttons/GhostButton";
import GhostButton from "./ui/buttons/GhostButton";
export default function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false);

View File

@@ -1,11 +1,11 @@
import Image from "next/image";
import IMAGES from "@/config/images";
import { Folder, Home } from "lucide-react";
import LanguageSettings from "./LanguageSettings";
import LanguageSettings from "../LanguageSettings";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import GhostButton from "./buttons/GhostButton";
import GhostButton from "../ui/buttons/GhostButton";
export async function Navbar() {
const t = await getTranslations("navbar");
@@ -44,7 +44,7 @@ export async function Navbar() {
(() => {
return session &&
<GhostButton href="/profile">{t("profile")}</GhostButton>
|| <GhostButton href="/signin">{t("sign_in")}</GhostButton>;
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
})()
}

View File

@@ -4,34 +4,66 @@ import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function signUpAction(formData: FormData) {
export interface SignUpFormData {
username: string;
email: string;
password: string;
}
export interface SignUpState {
success?: boolean;
message?: string;
errors?: {
username?: string[];
email?: string[];
password?: string[];
};
}
export async function signUpAction(prevState: SignUpState, formData: FormData) {
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string;
await auth.api.signUpEmail({
body: {
email,
password,
name
}
});
try {
await auth.api.signUpEmail({
body: {
email,
password,
name
}
});
redirect("/");
redirect(redirectTo || "/");
} catch (error) {
return {
success: false,
message: "注册失败,请稍后再试"
};
}
}
export async function signInAction(formData: FormData) {
export async function signInAction(prevState: SignUpState, formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string;
await auth.api.signInEmail({
body: {
email,
password,
}
});
try {
await auth.api.signInEmail({
body: {
email,
password,
}
});
redirect("/");
redirect(redirectTo || "/");
} catch (error) {
return {
success: false,
message: "登录失败,请检查您的邮箱和密码"
};
}
}
export async function signOutAction() {
@@ -39,5 +71,5 @@ export async function signOutAction() {
headers: await headers()
});
redirect("/login");
redirect("/auth");
}