This commit is contained in:
2025-10-31 12:28:28 +08:00
parent f5bb1ca507
commit b69dcbb52c
44 changed files with 648 additions and 163 deletions

View File

@@ -9,6 +9,7 @@ import {
useEffect,
useState,
} from "react";
import { useTranslations } from "next-intl";
export default function MemoryCard({
alphabet,
@@ -17,6 +18,7 @@ export default function MemoryCard({
alphabet: Letter[];
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) {
const t = useTranslations("alphabet");
const [index, setIndex] = useState(
Math.floor(Math.random() * alphabet.length),
);
@@ -79,7 +81,7 @@ export default function MemoryCard({
setLetterDisplay(!letterDisplay);
}}
>
{letterDisplay ? "隐藏字母" : "显示字母"}
{letterDisplay ? t("hideLetter") : t("showLetter")}
</LightButton>
<LightButton
className="w-20"
@@ -87,7 +89,7 @@ export default function MemoryCard({
setIPADisplay(!ipaDisplay);
}}
>
{ipaDisplay ? "隐藏IPA" : "显示IPA"}
{ipaDisplay ? t("hideIPA") : t("showIPA")}
</LightButton>
</>
) : (

View File

@@ -5,8 +5,10 @@ import { Letter, SupportedAlphabets } from "@/interfaces";
import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard";
import { Navbar } from "@/components/Navbar";
import { useTranslations } from "next-intl";
export default function Alphabet() {
const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] =
useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<
@@ -58,29 +60,29 @@ export default function Alphabet() {
<>
<Navbar></Navbar>
<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"></span>
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
<div className="flex gap-1 flex-wrap">
<LightButton onClick={() => setChosenAlphabet("japanese")}>
{t("japanese")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("english")}>
{t("english")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
{t("uyghur")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
{t("esperanto")}
</LightButton>
</div>
</div>
</>
);
if (loadingState === "loading") {
return "加载中...";
return t("loading");
}
if (loadingState === "error") {
return "加载失败,请重试";
return t("loadFailed");
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
export const viewport: Viewport = {
width: "device-width",
@@ -23,7 +24,7 @@ export const metadata: Metadata = {
description: "A Website to Learn Languages",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
@@ -33,7 +34,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);

View File

@@ -5,6 +5,7 @@ import { LOCALES } from "@/config/locales";
import { Dispatch, SetStateAction, useState } from "react";
import { WordData } from "@/interfaces";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
@@ -19,6 +20,7 @@ export default function Choose({
setWordData,
localeKey,
}: Props) {
const t = useTranslations("memorize.choose");
const [chosenLocale, setChosenLocale] = useState<
(typeof LOCALES)[number] | null
>(null);
@@ -53,8 +55,10 @@ export default function Choose({
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={handleChooseClick}>Choose</LightButton>
<LightButton onClick={() => setEditPage("edit")}>Back</LightButton>
<LightButton onClick={handleChooseClick}>{t("choose")}</LightButton>
<LightButton onClick={() => setEditPage("edit")}>
{t("back")}
</LightButton>
</BCard>
</div>
</ACard>

View File

@@ -6,6 +6,7 @@ import DarkButton from "@/components/buttons/DarkButton";
import { WordData } from "@/interfaces";
import Choose from "./Choose";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
@@ -14,6 +15,7 @@ interface Props {
}
export default function Edit({ setPage, wordData, setWordData }: Props) {
const t = useTranslations("memorize.edit");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [localeKey, setLocaleKey] = useState<0 | 1>(0);
const [editPage, setEditPage] = useState<"choose" | "edit">("edit");
@@ -65,15 +67,17 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
></textarea>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={() => setPage("main")}>Back</LightButton>
<LightButton onClick={handleSave}>Save Pairs</LightButton>
<LightButton onClick={() => setPage("main")}>
{t("back")}
</LightButton>
<LightButton onClick={handleSave}>{t("save")}</LightButton>
<DarkButton
onClick={() => {
setLocaleKey(0);
setEditPage("choose");
}}
>
Locale 1
{t("locale1")}
</DarkButton>
<DarkButton
onClick={() => {
@@ -81,7 +85,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
setEditPage("choose");
}}
>
Locale 2
{t("locale2")}
</DarkButton>
</BCard>
</div>

View File

@@ -5,6 +5,7 @@ import { WordData, WordDataSchema } from "@/interfaces";
import { Dispatch, SetStateAction } from "react";
import useFileUpload from "@/hooks/useFileUpload";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
wordData: WordData;
@@ -17,6 +18,7 @@ export default function Main({
setWordData,
setPage: setPage,
}: Props) {
const t = useTranslations("memorize.main");
const { upload, inputRef } = useFileUpload(async (file) => {
try {
const obj = JSON.parse(await file.text());
@@ -44,21 +46,25 @@ export default function Main({
<NavbarCenterWrapper className="bg-gray-100">
<ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
Memorize
{t("title")}
</h1>
<div className="flex-1 font-serif text-2xl w-full h-full text-gray-800">
<BCard>
<p>locale 1 {wordData.locales[0]}</p>
<p>locale 2 {wordData.locales[1]}</p>
<p>total {wordData.wordPairs.length} word pairs</p>
<p>{t("locale1", { locale: wordData.locales[0] })}</p>
<p>{t("locale2", { locale: wordData.locales[1] })}</p>
<p>{t("total", { total: wordData.wordPairs.length })}</p>
</BCard>
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={() => setPage("start")}></LightButton>
<LightButton onClick={handleLoad}></LightButton>
<LightButton onClick={handleSave}></LightButton>
<LightButton onClick={() => setPage("edit")}></LightButton>
<LightButton onClick={() => setPage("start")}>
{t("start")}
</LightButton>
<LightButton onClick={handleLoad}>{t("import")}</LightButton>
<LightButton onClick={handleSave}>{t("save")}</LightButton>
<LightButton onClick={() => setPage("edit")}>
{t("edit")}
</LightButton>
</BCard>
</div>
</ACard>

View File

@@ -5,6 +5,7 @@ import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/utils";
import { VOICES } from "@/config/locales";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface WordBoardProps {
children: React.ReactNode;
@@ -22,6 +23,7 @@ interface Props {
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Start({ wordData, setPage }: Props) {
const t = useTranslations("memorize.start");
const [display, setDisplay] = useState<"ask" | "show">("ask");
const [wordPair, setWordPair] = useState(
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
@@ -71,23 +73,25 @@ export default function Start({ wordData, setPage }: Props) {
<div className="w-full flex items-center justify-center">
<div className="flex gap-2 justify-center items-center w-fit font-mono flex-wrap">
{display === "ask" ? (
<LightButton onClick={show}>Show</LightButton>
<LightButton onClick={show}>{t("show")}</LightButton>
) : (
<LightButton onClick={next}>Next</LightButton>
<LightButton onClick={next}>{t("next")}</LightButton>
)}
<LightButton
onClick={() => setReverse(!reverse)}
selected={reverse}
>
Reverse
{t("reverse")}
</LightButton>
<LightButton
onClick={() => setDictation(!dictation)}
selected={dictation}
>
Dictation
{t("dictation")}
</LightButton>
<LightButton onClick={() => setPage("main")}>
{t("back")}
</LightButton>
<LightButton onClick={() => setPage("main")}>Exit</LightButton>
</div>
</div>
</div>

View File

@@ -1,108 +1,104 @@
import { Navbar } from "@/components/Navbar";
import { useTranslations } from "next-intl";
import Link from "next/link";
function TopArea() {
return (
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
<p className="text-2xl md:text-5xl">
Here is a very useful website to help you learn almost every language
in the world, including constructed ones.
</p>
</div>
</div>
);
}
interface LinkAreaProps {
href: string;
name: string;
description: string;
color: string;
}
function LinkArea({ href, name, description, color }: LinkAreaProps) {
return (
<Link
href={href}
style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="text-4xl">{name}</h1>
<p className="text-xl">{description}</p>
</div>
</Link>
);
}
function LinkGrid() {
return (
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
<LinkArea
href="/translator"
name="翻译器"
description="翻译到任何语言并标注国际音标IPA"
color="#a56068"
></LinkArea>
<LinkArea
href="/text-speaker"
name="朗读器"
description="识别并朗读文本,支持循环朗读、朗读速度调节"
color="#578aad"
></LinkArea>
{/* <LinkArea
href="/word-board"
name="词墙"
description="将单词固定到一片区域,高效便捷地记忆单词"
color="#e9b353"></LinkArea> */}
<LinkArea
href="/srt-player"
name="逐句视频播放器"
description="基于SRT字幕文件逐句播放视频以模仿母语者的发音"
color="#3c988d"
></LinkArea>
<LinkArea
href="/alphabet"
name="背字母"
description="从字母表开始新语言的学习"
color="#dd7486"
></LinkArea>
<LinkArea
href="/memorize"
name="背单词"
description="语言A到语言B语言B到语言A支持听写"
color="#cc9988"
></LinkArea>
<LinkArea
href="#"
name="更多功能"
description="开发中,敬请期待"
color="#cab48a"
></LinkArea>
</div>
);
}
function Fortune() {
return (
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
<p className="text-3xl">Stay hungry, stay foolish.</p>
<cite className="text-[#e9b353] text-xl"> Steve Jobs</cite>
</div>
);
}
function Explore() {
return (
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-52">
<span className="text-[100px] text-white"></span>
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
</div>
);
}
export default function Home() {
const t = useTranslations("home");
function TopArea() {
return (
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
{t("title")}
</h1>
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
</div>
</div>
);
}
interface LinkAreaProps {
href: string;
name: string;
description: string;
color: string;
}
function LinkArea({ href, name, description, color }: LinkAreaProps) {
return (
<Link
href={href}
style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="text-4xl">{name}</h1>
<p className="text-xl">{description}</p>
</div>
</Link>
);
}
function LinkGrid() {
return (
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
<LinkArea
href="/translator"
name={t("translator.name")}
description={t("translator.description")}
color="#a56068"
></LinkArea>
<LinkArea
href="/text-speaker"
name={t("textSpeaker.name")}
description={t("textSpeaker.description")}
color="#578aad"
></LinkArea>
{/* <LinkArea
href="/word-board"
name="词墙"
description="将单词固定到一片区域,高效便捷地记忆单词"
color="#e9b353"></LinkArea> */}
<LinkArea
href="/srt-player"
name={t("srtPlayer.name")}
description={t("srtPlayer.description")}
color="#3c988d"
></LinkArea>
<LinkArea
href="/alphabet"
name={t("alphabet.name")}
description={t("alphabet.description")}
color="#dd7486"
></LinkArea>
<LinkArea
href="/memorize"
name={t("memorize.name")}
description={t("memorize.description")}
color="#cc9988"
></LinkArea>
<LinkArea
href="#"
name={t("moreFeatures.name")}
description={t("moreFeatures.description")}
color="#cab48a"
></LinkArea>
</div>
);
}
function Fortune() {
return (
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
<p className="text-3xl">{t("fortune.quote")}</p>
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
</div>
);
}
function Explore() {
return (
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-52">
<span className="text-[100px] text-white">{t("explore")}</span>
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
</div>
);
}
return (
<>
<Navbar></Navbar>

View File

@@ -1,5 +1,6 @@
import LightButton from "@/components/buttons/LightButton";
import { useRef } from "react";
import { useTranslations } from "next-intl";
export default function UploadArea({
setVideoUrl,
@@ -8,6 +9,7 @@ export default function UploadArea({
setVideoUrl: (url: string | null) => void;
setSrtUrl: (url: string | null) => void;
}) {
const t = useTranslations("srt-player");
const inputRef = useRef<HTMLInputElement>(null);
const uploadVideo = () => {
@@ -38,8 +40,8 @@ export default function UploadArea({
};
return (
<div className="w-full flex flex-col gap-2 m-2">
<LightButton onClick={uploadVideo}></LightButton>
<LightButton onClick={uploadSRT}></LightButton>
<LightButton onClick={uploadVideo}>{t("uploadVideo")}</LightButton>
<LightButton onClick={uploadSRT}>{t("uploadSubtitle")}</LightButton>
<input type="file" className="hidden" ref={inputRef} />
</div>
);

View File

@@ -2,6 +2,7 @@ import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";
type VideoPanelProps = {
videoUrl: string | null;
@@ -10,6 +11,7 @@ type VideoPanelProps = {
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
({ videoUrl, srtUrl }, videoRef) => {
const t = useTranslations("srt-player");
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
@@ -185,14 +187,14 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap">
<LightButton onClick={togglePlayPause}>
{isPlaying ? "暂停" : "播放"}
{isPlaying ? t("pause") : t("play")}
</LightButton>
<LightButton onClick={previous}>{t("previous")}</LightButton>
<LightButton onClick={next}>{t("next")}</LightButton>
<LightButton onClick={restart}>{t("restart")}</LightButton>
<LightButton onClick={handleAutoPauseToggle}>
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
</LightButton>
<LightButton onClick={previous}></LightButton>
<LightButton onClick={next}></LightButton>
<LightButton onClick={restart}></LightButton>
<LightButton
onClick={handleAutoPauseToggle}
>{`自动暂停(${autoPause ? "是" : "否"})`}</LightButton>
</div>
<input
className="seekbar"

View File

@@ -6,6 +6,7 @@ import z from "zod";
import { TextSpeakerItemSchema } from "@/interfaces";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>;
@@ -47,6 +48,7 @@ interface SaveListProps {
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text-speaker");
const [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
@@ -61,7 +63,7 @@ export default function SaveList({ show = false, handleUse }: SaveListProps) {
setData(getTextSpeakerData());
};
const handleDeleteAll = () => {
const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
refresh();

View File

@@ -15,8 +15,10 @@ import { TextSpeakerItemSchema } from "@/interfaces";
import z from "zod";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
export default function TextSpeaker() {
const t = useTranslations("text-speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false);
@@ -320,7 +322,7 @@ export default function TextSpeaker() {
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
IPA
{t("generateIPA")}
</LightButton>
<LightButton
onClick={() => {
@@ -328,7 +330,7 @@ export default function TextSpeaker() {
}}
selected={showSaveList}
>
{t("viewSavedItems")}
</LightButton>
</div>
</div>

View File

@@ -8,8 +8,10 @@ import IMAGES from "@/config/images";
import { getTTSAudioUrl } from "@/utils";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
export default function Translator() {
const t = useTranslations("translator");
const [ipaEnabled, setIPAEnabled] = useState(true);
const [targetLang, setTargetLang] = useState("Chinese");
@@ -25,7 +27,7 @@ export default function Translator() {
const tl = ["Chinese", "English", "Italian"];
const inputLanguage = () => {
const lang = prompt("Input a language.")?.trim();
const lang = prompt(t("inputLanguage"))?.trim();
if (lang) {
setTargetLang(lang);
}
@@ -210,12 +212,12 @@ export default function Translator() {
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>detect language</span>
<span>{t("detectLanguage")}</span>
<LightButton
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
generate ipa
{t("generateIPA")}
</LightButton>
</div>
</div>
@@ -242,14 +244,14 @@ export default function Translator() {
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>translate into</span>
<span>{t("translateInto")}</span>
<LightButton
onClick={() => {
setTargetLang("Chinese");
}}
selected={targetLang === "Chinese"}
>
Chinese
{t("chinese")}
</LightButton>
<LightButton
onClick={() => {
@@ -257,7 +259,7 @@ export default function Translator() {
}}
selected={targetLang === "English"}
>
English
{t("english")}
</LightButton>
<LightButton
onClick={() => {
@@ -265,13 +267,13 @@ export default function Translator() {
}}
selected={targetLang === "Italian"}
>
Italian
{t("italian")}
</LightButton>
<LightButton
onClick={inputLanguage}
selected={!tl.includes(targetLang)}
>
{"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)}
{t("other") + (tl.includes(targetLang) ? "" : ": " + targetLang)}
</LightButton>
</div>
</div>
@@ -282,7 +284,7 @@ export default function Translator() {
onClick={translate}
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
>
{translating ? "translating..." : "translate"}
{translating ? t("translating") : t("translate")}
</button>
</div>
</>

View File

@@ -6,6 +6,7 @@ interface IconClickProps {
onClick?: () => void;
className?: string;
size?: number;
disableOnHoverBgChange?: boolean;
}
export default function IconClick({
src,
@@ -13,12 +14,13 @@ export default function IconClick({
onClick = () => {},
className = "",
size = 32,
disableOnHoverBgChange = false,
}: IconClickProps) {
return (
<>
<div
onClick={onClick}
className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}
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,5 +1,12 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import IconClick from "./IconClick";
import IMAGES from "@/config/images";
import { useState } from "react";
import LightButton from "./buttons/LightButton";
function MyLink({ href, label }: { href: string; label: string }) {
return (
@@ -9,6 +16,15 @@ function MyLink({ href, label }: { href: string; label: string }) {
);
}
export function Navbar() {
const t = useTranslations("navbar");
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
const handleLanguageClick = () => {
setShowLanguageMenu((prev) => !prev);
};
const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`;
window.location.reload();
};
return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={"/"} className="text-xl flex">
@@ -19,13 +35,39 @@ export function Navbar() {
height="32"
className="rounded-4xl"
></Image>
<span className="font-bold"></span>
<span className="font-bold">{t("title")}</span>
</Link>
<div className="flex gap-4 text-xl">
<MyLink href="/changelog.txt" label="关于"></MyLink>
<div className="relative">
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<LightButton
className="w-full"
onClick={() => setLocale("en-US")}
>
English
</LightButton>
<LightButton
className="w-full"
onClick={() => setLocale("zh-CN")}
>
</LightButton>
</div>
</div>
)}
<IconClick
src={IMAGES.language_white}
alt="language"
disableOnHoverBgChange={true}
onClick={handleLanguageClick}
></IconClick>
</div>
<MyLink href="/changelog.txt" label={t("about")}></MyLink>
<MyLink
href="https://github.com/GoddoNebianU/learn-languages"
label="源码"
label={t("sourceCode")}
></MyLink>
</div>
</div>

2
src/config/i18n.ts Normal file
View File

@@ -0,0 +1,2 @@
export const SUPPORTED_LOCALES = ["en-US", "zh-CN"];
export const DEFAULT_LOCALE = "en-US";

View File

@@ -15,6 +15,8 @@ const IMAGES = {
save: "/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
language_black: "/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
language_white: "/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg",
};
export default IMAGES;

53
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,53 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@/config/i18n";
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
import { readFileSync, readdirSync, statSync } from "fs";
import { join } from "path";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function loadMessagesFromDir(dirPath: string): Record<string, any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: Record<string, any> = {};
try {
const items = readdirSync(dirPath);
for (const item of items) {
const fullPath = join(dirPath, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
const dirMessages = loadMessagesFromDir(fullPath);
Object.assign(messages, { [item]: dirMessages });
} else if (item.endsWith(".json")) {
try {
const content = readFileSync(fullPath, "utf-8");
const jsonContent = JSON.parse(content);
Object.assign(messages, { [item.replace(".json", "")]: jsonContent });
} catch (error) {
console.warn(`Failed to load JSON file ${fullPath}:`, error);
}
}
}
} catch (error) {
console.warn(`Failed to read directory ${dirPath}:`, error);
}
return messages;
}
export default getRequestConfig(async () => {
const store = await cookies();
const locale = (() => {
const locale = store.get("locale")?.value ?? DEFAULT_LOCALE;
if (!SUPPORTED_LOCALES.includes(locale)) return DEFAULT_LOCALE;
return locale;
})();
const messagesPath = join(process.cwd(), "messages", locale);
const messages = loadMessagesFromDir(messagesPath);
return {
locale,
messages,
};
});