This commit is contained in:
@@ -10,7 +10,11 @@
|
|||||||
"hideLetter": "Hide Letter",
|
"hideLetter": "Hide Letter",
|
||||||
"showLetter": "Show Letter",
|
"showLetter": "Show Letter",
|
||||||
"hideIPA": "Hide IPA",
|
"hideIPA": "Hide IPA",
|
||||||
"showIPA": "Show IPA"
|
"showIPA": "Show IPA",
|
||||||
|
"roman": "Romanization",
|
||||||
|
"letter": "Letter",
|
||||||
|
"random": "Random Mode",
|
||||||
|
"randomNext": "Random Next"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Folders",
|
"title": "Folders",
|
||||||
@@ -82,6 +86,30 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"githubLogin": "GitHub Login"
|
"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": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
"selectFolder": "Select a folder",
|
"selectFolder": "Select a folder",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"hideLetter": "隐藏字母",
|
"hideLetter": "隐藏字母",
|
||||||
"showLetter": "显示字母",
|
"showLetter": "显示字母",
|
||||||
"hideIPA": "隐藏IPA",
|
"hideIPA": "隐藏IPA",
|
||||||
"showIPA": "显示IPA"
|
"showIPA": "显示IPA",
|
||||||
|
"roman": "罗马音",
|
||||||
|
"letter": "字母",
|
||||||
|
"random": "随机模式",
|
||||||
|
"randomNext": "随机下一个"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "文件夹",
|
"title": "文件夹",
|
||||||
@@ -82,6 +86,30 @@
|
|||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"githubLogin": "GitHub登录"
|
"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": {
|
"memorize": {
|
||||||
"choose": {
|
"choose": {
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
|
|||||||
258
src/app/(features)/alphabet/AlphabetCard.tsx
Normal file
258
src/app/(features)/alphabet/AlphabetCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -19,25 +20,26 @@ export default function MemoryCard({
|
|||||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
const [index, setIndex] = useState(
|
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
|
||||||
Math.floor(Math.random() * alphabet.length),
|
|
||||||
);
|
|
||||||
const [more, setMore] = useState(false);
|
const [more, setMore] = useState(false);
|
||||||
const [ipaDisplay, setIPADisplay] = useState(true);
|
const [ipaDisplay, setIPADisplay] = useState(true);
|
||||||
const [letterDisplay, setLetterDisplay] = useState(true);
|
const [letterDisplay, setLetterDisplay] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
if (alphabet.length > 0) {
|
||||||
|
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
|
}
|
||||||
|
}, [alphabet.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
||||||
if (e.key === " ") refresh();
|
if (e.key === " ") refresh();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeydown);
|
document.addEventListener("keydown", handleKeydown);
|
||||||
return () => document.removeEventListener("keydown", handleKeydown);
|
return () => document.removeEventListener("keydown", handleKeydown);
|
||||||
});
|
}, [refresh]);
|
||||||
|
|
||||||
const letter = alphabet[index];
|
const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
|
||||||
const refresh = () => {
|
|
||||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full flex justify-center items-center"
|
className="w-full flex justify-center items-center"
|
||||||
|
|||||||
@@ -1,48 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import { useState, useEffect } from "react";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import MemoryCard from "./MemoryCard";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
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() {
|
export default function Alphabet() {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
const [chosenAlphabet, setChosenAlphabet] =
|
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
|
||||||
useState<SupportedAlphabets | null>(null);
|
const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
|
||||||
const [alphabetData, setAlphabetData] = useState<
|
const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
Record<SupportedAlphabets, Letter[] | null>
|
|
||||||
>({
|
|
||||||
japanese: null,
|
|
||||||
english: null,
|
|
||||||
esperanto: null,
|
|
||||||
uyghur: null,
|
|
||||||
});
|
|
||||||
const [loadingState, setLoadingState] = useState<
|
|
||||||
"idle" | "loading" | "success" | "error"
|
|
||||||
>("idle");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
|
const loadAlphabetData = async () => {
|
||||||
setLoadingState("loading");
|
if (chosenAlphabet && !alphabetData) {
|
||||||
|
try {
|
||||||
fetch("/alphabets/" + chosenAlphabet + ".json")
|
setLoadingState("loading");
|
||||||
.then((res) => {
|
|
||||||
|
const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
|
||||||
if (!res.ok) throw new Error("Network response was not ok");
|
if (!res.ok) throw new Error("Network response was not ok");
|
||||||
return res.json();
|
|
||||||
})
|
const obj = await res.json();
|
||||||
.then((obj) => {
|
setAlphabetData(obj as Letter[]);
|
||||||
setAlphabetData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[chosenAlphabet]: obj as Letter[],
|
|
||||||
}));
|
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {
|
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAlphabetData();
|
||||||
}, [chosenAlphabet, alphabetData]);
|
}, [chosenAlphabet, alphabetData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,48 +39,106 @@ export default function Alphabet() {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setChosenAlphabet(null);
|
setChosenAlphabet(null);
|
||||||
|
setAlphabetData(null);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [loadingState]);
|
}, [loadingState]);
|
||||||
|
|
||||||
if (!chosenAlphabet)
|
// 语言选择界面
|
||||||
|
if (!chosenAlphabet) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
||||||
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
<Container className="p-8 max-w-2xl w-full text-center">
|
||||||
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
<div className="flex gap-1 flex-wrap">
|
{t("chooseCharacters")}
|
||||||
<LightButton onClick={() => setChosenAlphabet("japanese")}>
|
</h1>
|
||||||
{t("japanese")}
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Container>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
if (loadingState === "loading") {
|
if (loadingState === "loading") {
|
||||||
return t("loading");
|
|
||||||
}
|
|
||||||
if (loadingState === "error") {
|
|
||||||
return t("loadFailed");
|
|
||||||
}
|
|
||||||
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||||
<MemoryCard
|
<Container className="p-8 text-center">
|
||||||
alphabet={alphabetData[chosenAlphabet]}
|
<div className="text-2xl text-gray-600">{t("loading")}</div>
|
||||||
setChosenAlphabet={setChosenAlphabet}
|
</Container>
|
||||||
></MemoryCard>
|
</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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Container from "@/components/cards/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/common/Center";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
import { VOICES } from "@/config/locales";
|
import { VOICES } from "@/config/locales";
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function MemorizePage({
|
|||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect(
|
redirect(
|
||||||
`/login?redirect=/memorize${(await searchParams).folder_id
|
`/auth?redirect=/memorize${(await searchParams).folder_id
|
||||||
? `?folder_id=${tParam}`
|
? `?folder_id=${tParam}`
|
||||||
: ""
|
: ""
|
||||||
}`,
|
}`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||||
import SubtitleDisplay from "./SubtitleDisplay";
|
import SubtitleDisplay from "./SubtitleDisplay";
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
TextSpeakerItemSchema,
|
TextSpeakerItemSchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import Container from "@/components/cards/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
import { Dispatch, useEffect, useState } from "react";
|
import { Dispatch, useEffect, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Container from "@/components/cards/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
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";
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
interface FolderSelectorProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { VOICES } from "@/config/locales";
|
import { VOICES } from "@/config/locales";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
|||||||
209
src/app/auth/AuthForm.tsx
Normal file
209
src/app/auth/AuthForm.tsx
Normal 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
20
src/app/auth/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/common/Center";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Folder } from "../../../generated/prisma/browser";
|
import { Folder } from "../../../generated/prisma/browser";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/ui/Input";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -83,13 +83,19 @@ export default function AddTextPairModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale1")}
|
{t("locale1")}
|
||||||
<Input ref={input3Ref} className="w-full"
|
<Input
|
||||||
placeholder="en-US"></Input>
|
ref={input3Ref}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="en-US"
|
||||||
|
></Input>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale2")}
|
{t("locale2")}
|
||||||
<Input ref={input4Ref} className="w-full"
|
<Input
|
||||||
placeholder="zh-CN"></Input>
|
ref={input4Ref}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="zh-CN"
|
||||||
|
></Input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/common/Center";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import Container from "@/components/cards/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import {
|
import {
|
||||||
createPair,
|
createPair,
|
||||||
deletePairById,
|
deletePairById,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@/lib/server/services/pairService";
|
} from "@/lib/server/services/pairService";
|
||||||
import AddTextPairModal from "./AddTextPairModal";
|
import AddTextPairModal from "./AddTextPairModal";
|
||||||
import TextPairCard from "./TextPairCard";
|
import TextPairCard from "./TextPairCard";
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export interface TextPair {
|
export interface TextPair {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/ui/Input";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function FoldersPage({
|
|||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
redirect("/folders");
|
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) {
|
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
||||||
return <p>{t("unauthorized")}</p>;
|
return <p>{t("unauthorized")}</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export default async function FoldersPage() {
|
|||||||
const session = await auth.api.getSession(
|
const session = await auth.api.getSession(
|
||||||
{ headers: await headers() }
|
{ headers: await headers() }
|
||||||
);
|
);
|
||||||
if (!session) redirect(`/signin?redirect=/folders`);
|
if (!session) redirect(`/auth?redirect=/folders`);
|
||||||
return <FoldersClient userId={session.user.id} />;
|
return <FoldersClient userId={session.user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Viewport } from "next";
|
import type { Viewport } from "next";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -12,7 +12,7 @@ export default function LogoutButton() {
|
|||||||
authClient.signOut({
|
authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push("/login?redirect=/profile");
|
router.push("/auth?redirect=/profile");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/common/Center";
|
||||||
import Container from "@/components/cards/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -13,7 +13,7 @@ export default async function ProfilePage() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/signin?redirect=/profile");
|
redirect("/auth?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(session, null, 2));
|
console.log(JSON.stringify(session, null, 2));
|
||||||
@@ -22,7 +22,7 @@ export default async function ProfilePage() {
|
|||||||
<Center>
|
<Center>
|
||||||
<Container className="p-6">
|
<Container className="p-6">
|
||||||
<h1>{t("myProfile")}</h1>
|
<h1>{t("myProfile")}</h1>
|
||||||
{(session.user.image) && (
|
{session.user.image && (
|
||||||
<Image
|
<Image
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
@@ -37,4 +37,4 @@ export default async function ProfilePage() {
|
|||||||
</Container>
|
</Container>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import IconClick from "./IconClick";
|
import IconClick from "./ui/buttons/IconClick";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import GhostButton from "./buttons/GhostButton";
|
import GhostButton from "./ui/buttons/GhostButton";
|
||||||
|
|
||||||
export default function LanguageSettings() {
|
export default function LanguageSettings() {
|
||||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Folder, Home } from "lucide-react";
|
import { Folder, Home } from "lucide-react";
|
||||||
import LanguageSettings from "./LanguageSettings";
|
import LanguageSettings from "../LanguageSettings";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import GhostButton from "./buttons/GhostButton";
|
import GhostButton from "../ui/buttons/GhostButton";
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const t = await getTranslations("navbar");
|
const t = await getTranslations("navbar");
|
||||||
@@ -44,7 +44,7 @@ export async function Navbar() {
|
|||||||
(() => {
|
(() => {
|
||||||
return session &&
|
return session &&
|
||||||
<GhostButton href="/profile">{t("profile")}</GhostButton>
|
<GhostButton href="/profile">{t("profile")}</GhostButton>
|
||||||
|| <GhostButton href="/signin">{t("sign_in")}</GhostButton>;
|
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
|
||||||
|
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -4,34 +4,66 @@ import { auth } from "@/auth";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
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 email = formData.get("email") as string;
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
|
const redirectTo = formData.get("redirectTo") as string;
|
||||||
|
|
||||||
await auth.api.signUpEmail({
|
try {
|
||||||
body: {
|
await auth.api.signUpEmail({
|
||||||
email,
|
body: {
|
||||||
password,
|
email,
|
||||||
name
|
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 email = formData.get("email") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
|
const redirectTo = formData.get("redirectTo") as string;
|
||||||
|
|
||||||
await auth.api.signInEmail({
|
try {
|
||||||
body: {
|
await auth.api.signInEmail({
|
||||||
email,
|
body: {
|
||||||
password,
|
email,
|
||||||
}
|
password,
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/");
|
redirect(redirectTo || "/");
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "登录失败,请检查您的邮箱和密码"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signOutAction() {
|
export async function signOutAction() {
|
||||||
@@ -39,5 +71,5 @@ export async function signOutAction() {
|
|||||||
headers: await headers()
|
headers: await headers()
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect("/login");
|
redirect("/auth");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user