完成了对记忆功能的升级
Some checks reported errors
continuous-integration/drone/push Build was killed

This commit is contained in:
2025-11-15 22:16:12 +08:00
parent cf3cb916b7
commit 72c6791d93
30 changed files with 363 additions and 450 deletions

2
pnpm-lock.yaml generated
View File

@@ -7355,7 +7355,7 @@ snapshots:
dotenv-expand@11.0.7: dotenv-expand@11.0.7:
dependencies: dependencies:
dotenv: 16.4.7 dotenv: 16.6.1
optional: true optional: true
dotenv@16.4.7: dotenv@16.4.7:

36
public/images/logo.svg Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180.425mm"
height="66.658363mm"
viewBox="0 0 180.425 66.658363"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-19.117989,-118.50376)">
<rect
style="fill:#00ccff;stroke-width:4.38923"
id="rect1"
width="180.42502"
height="66.658356"
x="19.117989"
y="118.50375" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:52.6706px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#f2f2f2;stroke-width:4.38923"
x="29.942305"
y="167.45377"
id="text1"
transform="scale(0.98306332,1.0172285)"><tspan
id="tspan1"
style="fill:#f2f2f2;stroke-width:4.38923"
x="29.942305"
y="167.45377">Learn!</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,53 @@
"use client";
import Container from "@/components/cards/Container";
import { folder } from "../../../../generated/prisma/client";
import { Folder } from "lucide-react";
import { useRouter } from "next/navigation";
import { Center } from "@/components/Center";
interface FolderSelectorProps {
folders: (folder & { total_pairs: number })[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const router = useRouter();
return (
<Center>
<Container className="p-6 gap-4 flex flex-col">
{(folders.length === 0 && (
<h1 className="text-2xl text-gray-900 font-light">
No folders found.
</h1>
)) || (
<>
<h1 className="text-2xl text-gray-900 font-light">
Select a folder:
</h1>
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
>
<Folder />
<div className="flex-1 flex gap-2">
<span className="group-hover:text-blue-500">
{folder.name}
</span>
<span>({folder.total_pairs})</span>
</div>
</div>
))}
</div>
</>
)}
</Container>
</Center>
);
};
export default FolderSelector;

View File

@@ -0,0 +1,115 @@
"use client";
import { Center } from "@/components/Center";
import { text_pair } from "../../../../generated/prisma/browser";
import Container from "@/components/cards/Container";
import { useState } from "react";
import LightButton from "@/components/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/tts";
import { VOICES } from "@/config/locales";
interface MemorizeProps {
textPairs: text_pair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
return (
<Center>
<Container className="p-6 flex flex-col gap-8 h-96 justify-center items-center">
{(textPairs.length > 0 && (
<>
<div className="h-36 flex flex-col gap-2 justify-start items-center font-serif text-3xl">
<div className="text-sm text-gray-500">
{index + 1}/{textPairs.length}
</div>
{dictation ? (
show === "question" ? (
""
) : (
<>
<div>
{reverse
? textPairs[index].text2
: textPairs[index].text1}
</div>
<div>
{reverse
? textPairs[index].text1
: textPairs[index].text2}
</div>
</>
)
) : (
<>
<div>
{reverse ? textPairs[index].text2 : textPairs[index].text1}
</div>
<div>
{show === "answer"
? reverse
? textPairs[index].text1
: textPairs[index].text2
: ""}
</div>
</>
)}
</div>
<div className="flex flex-row gap-2 items-center justify-center">
<LightButton
className="w-32"
onClick={async () => {
if (show === "answer") {
const newIndex = (index + 1) % textPairs.length;
setIndex(newIndex);
if (dictation)
getTTSAudioUrl(
textPairs[newIndex][reverse ? "text2" : "text1"],
VOICES.find(
(v) =>
v.locale ===
textPairs[newIndex][
reverse ? "locale2" : "locale1"
],
)!.short_name,
).then((url) => {
load(url);
play();
});
}
setShow(show === "question" ? "answer" : "question");
}}
>
{show === "question" ? "Show Answer" : "Next"}
</LightButton>
<LightButton
onClick={() => {
setReverse(!reverse);
}}
selected={reverse}
>
Reverse
</LightButton>
<LightButton
onClick={() => {
setDictation(!dictation);
}}
selected={dictation}
>
Dictation
</LightButton>
</div>
</>
)) || <p>No text pairs available</p>}
</Container>
</Center>
);
};
export default Memorize;

View File

@@ -0,0 +1,45 @@
"use server";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
getFoldersByOwner,
getFoldersWithTotalPairsByOwner,
getOwnerByFolderId,
} from "@/lib/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize";
import { getTextPairsByFolderId } from "@/lib/services/textPairService";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string }>;
}) {
const session = await getServerSession();
const username = session?.user?.name;
const t = (await searchParams).folder_id;
const folder_id = t ? (isNonNegativeInteger(t) ? parseInt(t) : null) : null;
if (!username)
redirect(
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
);
if (!folder_id) {
return (
<FolderSelector
folders={await getFoldersWithTotalPairsByOwner(username)}
/>
);
}
const owner = await getOwnerByFolderId(folder_id);
if (owner !== username) {
return <p>访</p>;
}
return <Memorize textPairs={await getTextPairsByFolderId(folder_id)} />;
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import Container from "@/components/cards/Container";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
@@ -23,6 +24,9 @@ export default function TranslatorPage() {
const [ipaTexts, setIpaTexts] = useState(["", ""]); const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[]
>(tlso.get());
const lastTTS = useRef({ const lastTTS = useRef({
text: "", text: "",
@@ -64,6 +68,7 @@ export default function TranslatorPage() {
const checkUpdateLocalStorage = (item: typeof newItem) => { const checkUpdateLocalStorage = (item: typeof newItem) => {
if (item.text1 && item.text2 && item.locale1 && item.locale2) { if (item.text1 && item.text2 && item.locale1 && item.locale2) {
tlsoPush(item as z.infer<typeof TranslationHistorySchema>); tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
setHistory(tlso.get());
} }
}; };
const innerStates = { const innerStates = {
@@ -250,6 +255,18 @@ export default function TranslatorPage() {
{t("translate")} {t("translate")}
</button> </button>
</div> </div>
{history.length > 0 && (
<Container className="m-6 flex flex-col p-6">
<h1 className="text-2xl font-light">History</h1>
<ul className="list-disc list-inside">
{history.map((item, index) => (
<li key={index}>
<span className="font-bold">{item.text1}</span> - {item.text2}
</li>
))}
</ul>
</Container>
)}
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import TheBoard from "@/app/word-board/TheBoard"; import TheBoard from "@/app/(features)/word-board/TheBoard";
import LightButton from "../../components/buttons/LightButton"; import LightButton from "../../../components/buttons/LightButton";
import { KeyboardEvent, useRef, useState } from "react"; import { KeyboardEvent, useRef, useState } from "react";
import { Word } from "@/lib/interfaces"; import { Word } from "@/lib/interfaces";
import { import {

View File

@@ -1,6 +1,11 @@
"use client"; "use client";
import { ChevronRight, Folder, FolderPlus, Trash2 } from "lucide-react"; import {
ChevronRight,
Folder,
FolderPlus,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Center } from "@/components/Center"; import { Center } from "@/components/Center";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -3,7 +3,7 @@
import { ArrowLeft, Edit, Plus, Trash2, X } from "lucide-react"; import { ArrowLeft, Edit, Plus, Trash2, X } from "lucide-react";
import { Center } from "@/components/Center"; import { Center } from "@/components/Center";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { import {
createTextPair, createTextPair,
@@ -15,6 +15,7 @@ import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard"; import TextPairCard from "./TextPairCard";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";
import { text_pairUpdateInput } from "../../../../generated/prisma/models"; import { text_pairUpdateInput } from "../../../../generated/prisma/models";
import LightButton from "@/components/buttons/LightButton";
export interface TextPair { export interface TextPair {
id: number; id: number;
@@ -77,16 +78,28 @@ export default function InFolder({ folderId }: { folderId: number }) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<LightButton
onClick={() => {
redirect(`/memorize?folder_id=${folderId}`);
}}
>
Memorize
</LightButton>
<button <button
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors" className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => { onClick={() => {
setAddModal(true); setAddModal(true);
}} }}
> >
<Plus size={18} className="text-gray-600 hover:cursor-pointer" /> <Plus
size={18}
className="text-gray-600 hover:cursor-pointer"
/>
</button> </button>
</div> </div>
</div> </div>
</div>
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden"> <div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
{loading ? ( {loading ? (

View File

@@ -13,7 +13,7 @@ export default async function FoldersPage({
if (!id) { if (!id) {
redirect("/folders"); redirect("/folders");
} }
if (!session?.user?.name) redirect(`/login`); if (!session?.user?.name) redirect(`/login?redirect=/folders/${id}`);
if ((await getOwnerByFolderId(id)) !== session.user.name) { if ((await getOwnerByFolderId(id)) !== session.user.name) {
return "you are not the owner of this folder"; return "you are not the owner of this folder";
} }

View File

@@ -3,8 +3,6 @@ import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
export default async function FoldersPage() { export default async function FoldersPage() {
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user?.name) redirect(`/login`); if (!session?.user?.name) redirect(`/login?redirect=/folders`);
return ( return <FoldersClient username={session.user.name} />;
<FoldersClient username={session.user.name} />
);
} }

View File

@@ -1,67 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import { LOCALES } from "@/config/locales";
import { Dispatch, SetStateAction, useState } from "react";
import { WordData } from "@/lib/interfaces";
import { useTranslations } from "next-intl";
interface Props {
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
localeKey: 0 | 1;
}
export default function Choose({
setEditPage,
wordData,
setWordData,
localeKey,
}: Props) {
const t = useTranslations("memorize.choose");
const [chosenLocale, setChosenLocale] = useState<
(typeof LOCALES)[number] | null
>(null);
const handleChooseClick = () => {
if (chosenLocale) {
setWordData({
locales: [
localeKey === 0 ? chosenLocale : wordData.locales[0],
localeKey === 1 ? chosenLocale : wordData.locales[1],
],
wordPairs: wordData.wordPairs,
});
setEditPage("edit");
}
};
return (
<div className="w-screen flex justify-center items-center">
<ACard className="flex flex-col">
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-4 md:grid-cols-6 md:gap-2">
{LOCALES.map((locale, index) => (
<LightButton
key={index}
className="md:w-26 w-18"
selected={locale === chosenLocale}
onClick={() => setChosenLocale(locale)}
>
{locale}
</LightButton>
))}
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={handleChooseClick}>{t("choose")}</LightButton>
<LightButton onClick={() => setEditPage("edit")}>
{t("back")}
</LightButton>
</BCard>
</div>
</ACard>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import { ChangeEvent, Dispatch, SetStateAction, useRef, useState } from "react";
import DarkButton from "@/components/buttons/DarkButton";
import { WordData } from "@/lib/interfaces";
import Choose from "./Choose";
import { useTranslations } from "next-intl";
interface Props {
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
}
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");
const convertIntoWordData = (text: string) => {
const t1 = text
.replace("", ",")
.split("\n")
.map((v) => v.trim())
.filter((v) => v.includes(","));
const t2 = t1
.map((v) => {
const [left, right] = v.split(",", 2).map((v) => v.trim());
if (left && right) return [left, right] as [string, string];
else return null;
})
.filter((v) => v !== null);
const new_data: WordData = {
locales: [...wordData.locales],
wordPairs: t2,
};
return new_data;
};
const convertFromWordData = (wdata: WordData) => {
let result = "";
for (const pair of wdata.wordPairs) {
result += `${pair[0]}, ${pair[1]}\n`;
}
return result;
};
let input = convertFromWordData(wordData);
const handleSave = () => {
const newWordData = convertIntoWordData(input);
setWordData(newWordData);
if (textareaRef.current)
textareaRef.current.value = convertFromWordData(newWordData);
if (localStorage) {
localStorage.setItem("wordData", JSON.stringify(newWordData));
}
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value;
};
if (editPage === "edit")
return (
<div className="w-screen flex justify-center items-center">
<ACard className="flex flex-col">
<textarea
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey) handleSave();
}}
ref={textareaRef}
className="flex-1 text-gray-800 font-mono md:text-2xl border-gray-200 border rounded-2xl w-full resize-none outline-0 p-2"
defaultValue={input}
onChange={handleChange}
></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")}>
{t("back")}
</LightButton>
<LightButton onClick={handleSave}>{t("save")}</LightButton>
<DarkButton
onClick={() => {
setLocaleKey(0);
setEditPage("choose");
}}
>
{t("locale1")}
</DarkButton>
<DarkButton
onClick={() => {
setLocaleKey(1);
setEditPage("choose");
}}
>
{t("locale2")}
</DarkButton>
</BCard>
</div>
</ACard>
</div>
);
if (editPage === "choose")
return (
<Choose
wordData={wordData}
setEditPage={setEditPage}
setWordData={setWordData}
localeKey={localeKey}
></Choose>
);
}

View File

@@ -1,73 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import { WordData, WordDataSchema } from "@/lib/interfaces";
import { Dispatch, SetStateAction } from "react";
import useFileUpload from "@/hooks/useFileUpload";
import { useTranslations } from "next-intl";
interface Props {
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Main({
wordData,
setWordData,
setPage: setPage,
}: Props) {
const t = useTranslations("memorize.main");
const { upload, inputRef } = useFileUpload(async (file) => {
try {
const obj = JSON.parse(await file.text());
const newWordData = WordDataSchema.parse(obj);
setWordData(newWordData);
} catch (error) {
console.error(error);
}
});
const handleLoad = async () => {
upload("application/json");
};
const handleSave = () => {
const blob = new Blob([JSON.stringify(wordData)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "word_data.json";
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="w-screen flex justify-center items-center">
<ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
{t("title")}
</h1>
<div className="flex-1 font-serif text-2xl w-full h-full text-gray-800">
<BCard>
<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")}>
{t("start")}
</LightButton>
<LightButton onClick={handleLoad}>{t("import")}</LightButton>
<LightButton onClick={handleSave}>{t("export")}</LightButton>
<LightButton onClick={() => setPage("edit")}>
{t("edit")}
</LightButton>
</BCard>
</div>
</ACard>
<input type="file" hidden ref={inputRef}></input>
</div>
);
}

View File

@@ -1,100 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import { WordData } from "@/lib/interfaces";
import { Dispatch, SetStateAction, useState } from "react";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/utils";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
interface WordBoardProps {
children: React.ReactNode;
}
function WordBoard({ children }: WordBoardProps) {
return (
<div className="text-nowrap w-full h-36 border border-white rounded flex justify-center items-center text-4xl md:text-6xl font-serif overflow-x-auto">
{children}
</div>
);
}
interface Props {
wordData: WordData;
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)],
);
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const { load, play } = useAudioPlayer();
const show = () => {
setDisplay("show");
};
const next = async () => {
setDisplay("ask");
const newWordPair =
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)];
setWordPair(newWordPair);
if (dictation)
await load(
await getTTSAudioUrl(
newWordPair[reverse ? 1 : 0],
VOICES.find((v) => v.locale === wordData.locales[reverse ? 1 : 0])!
.short_name,
),
).then(play);
};
return (
<div className="w-screen flex justify-center items-center">
<div className="flex-col flex items-center h-96">
<div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
{dictation ? (
<>
{display === "show" && (
<>
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
</>
)}
</>
) : (
<>
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
{display === "show" && (
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
)}
</>
)}
</div>
<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}>{t("show")}</LightButton>
) : (
<LightButton onClick={next}>{t("next")}</LightButton>
)}
<LightButton
onClick={() => setReverse(!reverse)}
selected={reverse}
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => setDictation(!dictation)}
selected={dictation}
>
{t("dictation")}
</LightButton>
<LightButton onClick={() => setPage("main")}>
{t("back")}
</LightButton>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
"use client";
import { useState } from "react";
import Main from "./Main";
import Edit from "./Edit";
import Start from "./Start";
import { WordData, WordDataSchema } from "@/lib/interfaces";
const getLocalWordData = (): WordData => {
const data = localStorage.getItem("wordData");
if (!data) return {
locales: ['en-US', 'zh-CN'],
wordPairs: []
};
try {
const parsedData = JSON.parse(data);
const parsedData2 = WordDataSchema.parse(parsedData);
return parsedData2;
} catch (error) {
console.error(error);
return {
locales: ['en-US', 'zh-CN'],
wordPairs: []
};
}
}
export default function MemorizePage() {
const [page, setPage] = useState<"start" | "main" | "edit">("main");
const [wordData, setWordData] = useState<WordData>(getLocalWordData());
if (page === "main")
return (
<Main
wordData={wordData}
setWordData={setWordData}
setPage={setPage}
></Main>
);
if (page === "edit")
return (
<Edit
setPage={setPage}
wordData={wordData}
setWordData={setWordData}
></Edit>
);
if (page === "start")
return <Start setPage={setPage} wordData={wordData}></Start>;
}

View File

@@ -8,20 +8,8 @@ import IMAGES from "@/config/images";
import { useState } from "react"; import { useState } from "react";
import LightButton from "./buttons/LightButton"; import LightButton from "./buttons/LightButton";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Folder, Home } from "lucide-react";
function MyLink({
href,
children,
}: {
href: string;
children?: React.ReactNode;
}) {
return (
<Link className="font-bold" href={href}>
{children}
</Link>
);
}
export function Navbar() { export function Navbar() {
const t = useTranslations("navbar"); const t = useTranslations("navbar");
const [showLanguageMenu, setShowLanguageMenu] = useState(false); const [showLanguageMenu, setShowLanguageMenu] = useState(false);
@@ -35,20 +23,24 @@ export function Navbar() {
const session = useSession(); const session = useSession();
return ( return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white"> <div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<div className="flex gap-4 text-xl justify-center items-center"> <Link href={"/"} className="text-xl border-b hidden md:block">
<Link href={"/"} className="text-xl flex border-b"> {t("title")}
<Image </Link>
src={"/favicon.ico"} <Link className="block md:hidden" href={"/"}>
alt="logo" <Home />
width="32" </Link>
height="32" <div className="flex gap-4 text-xl justify-center items-center flex-wrap">
className="rounded-4xl" <Link
></Image> className="md:hidden block"
<span className="font-bold text-pink-200">{t("title")}</span> href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
src={IMAGES.github_mark_white}
alt="GitHub"
width={24}
height={24}
/>
</Link> </Link>
<MyLink href="/folders">{t("folders")}</MyLink>
</div>
<div className="flex gap-4 text-xl justify-center items-center">
<div className="relative"> <div className="relative">
{showLanguageMenu && ( {showLanguageMenu && (
<div> <div>
@@ -75,17 +67,26 @@ export function Navbar() {
onClick={handleLanguageClick} onClick={handleLanguageClick}
></IconClick> ></IconClick>
</div> </div>
<Link href="/folders" className="md:block hidden">
{t("folders")}
</Link>
<Link href="/folders" className="md:hidden block">
<Folder />
</Link>
{session?.status === "authenticated" ? ( {session?.status === "authenticated" ? (
<div className="flex gap-2"> <div className="flex gap-2">
<MyLink href="/profile">{t("profile")}</MyLink> <Link href="/profile">{t("profile")}</Link>
</div> </div>
) : ( ) : (
<MyLink href="/login">{t("login")}</MyLink> <Link href="/login">{t("login")}</Link>
)} )}
<MyLink href="/changelog.txt">{t("about")}</MyLink> <Link href="/changelog.txt">{t("about")}</Link>
<MyLink href="https://github.com/GoddoNebianU/learn-languages"> <Link
className="hidden md:block"
href="https://github.com/GoddoNebianU/learn-languages"
>
{t("sourceCode")} {t("sourceCode")}
</MyLink> </Link>
</div> </div>
</div> </div>
); );

View File

@@ -18,6 +18,7 @@ const IMAGES = {
language_black: "/images/language_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", language_white: "/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg",
github_mark: "/images/github-mark/github-mark.svg", github_mark: "/images/github-mark/github-mark.svg",
github_mark_white: "/images/github-mark/github-mark-white.svg",
}; };
export default IMAGES; export default IMAGES;

View File

@@ -1,6 +1,10 @@
"use server"; "use server";
import { folderCreateInput, folderUpdateInput } from "../../../generated/prisma/models"; import { folder } from "../../../generated/prisma/client";
import {
folderCreateInput,
folderUpdateInput,
} from "../../../generated/prisma/models";
import prisma from "../db"; import prisma from "../db";
export async function getFoldersByOwner(owner: string) { export async function getFoldersByOwner(owner: string) {
@@ -12,6 +16,26 @@ export async function getFoldersByOwner(owner: string) {
return folders; return folders;
} }
export async function getFoldersWithTotalPairsByOwner(owner: string) {
const folders = await prisma.folder.findMany({
where: {
owner: owner
},
include: {
text_pair: {
select: {
id: true
}
}
}
});
return folders.map(folder => ({
...folder,
total_pairs: folder.text_pair.length
}));
}
export async function createFolder(folder: folderCreateInput) { export async function createFolder(folder: folderCreateInput) {
await prisma.folder.create({ await prisma.folder.create({
data: folder, data: folder,

View File

@@ -124,3 +124,7 @@ export const letsFetch = (
}) })
.finally(onFinally); .finally(onFinally);
}; };
export function isNonNegativeInteger(str: string): boolean {
return /^\d+$/.test(str);
}