...
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
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";
|
||||||
@@ -15,49 +13,77 @@ interface FolderSelectorProps {
|
|||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
const t = useTranslations("memorize.folder_selector");
|
const t = useTranslations("memorize.folder_selector");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
<Container className="p-6 gap-4 flex flex-col">
|
<div className="w-full max-w-2xl">
|
||||||
{(folders.length === 0 && (
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
<h1 className="text-2xl text-gray-900 font-light">
|
{folders.length === 0 ? (
|
||||||
{t("noFolders")}
|
<div className="text-center">
|
||||||
<Link className="text-blue-900 border-b" href={"/folders"}>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||||
folders
|
{t("noFolders")}
|
||||||
</Link>
|
</h1>
|
||||||
</h1>
|
<Link
|
||||||
)) || (
|
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
|
||||||
<>
|
href="/folders"
|
||||||
<h1 className="text-2xl text-gray-900 font-light">
|
>
|
||||||
{t("selectFolder")}
|
Go to Folders
|
||||||
</h1>
|
</Link>
|
||||||
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
|
||||||
{folders
|
|
||||||
.toSorted((a, b) => a.id - b.id)
|
|
||||||
.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"
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
<div className="flex-1 flex gap-2">
|
|
||||||
<span className="group-hover:text-blue-500">
|
|
||||||
{t("folderInfo", {
|
|
||||||
id: folder.id,
|
|
||||||
name: folder.name,
|
|
||||||
count: folder.total,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</Container>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||||
</Center>
|
{t("selectFolder")}
|
||||||
|
</h1>
|
||||||
|
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/memorize?folder_id=${folder.id}`)
|
||||||
|
}
|
||||||
|
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Fd className="text-gray-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
count: folder.total,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
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";
|
||||||
@@ -28,7 +27,13 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
|
|
||||||
if (textPairs.length === 0) {
|
if (textPairs.length === 0) {
|
||||||
return <p>{t("noTextPairs")}</p>;
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||||
|
<p className="text-gray-700">{t("noTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rng = new SeededRandom(textPairs[0].folderId);
|
const rng = new SeededRandom(textPairs[0].folderId);
|
||||||
@@ -38,135 +43,160 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
|
|
||||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||||
|
|
||||||
|
const handleIndexClick = () => {
|
||||||
|
const newIndex = prompt("Input a index number.")?.trim();
|
||||||
|
if (
|
||||||
|
newIndex &&
|
||||||
|
isNonNegativeInteger(newIndex) &&
|
||||||
|
parseInt(newIndex) <= textPairs.length &&
|
||||||
|
parseInt(newIndex) > 0
|
||||||
|
) {
|
||||||
|
setIndex(parseInt(newIndex) - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (show === "answer") {
|
||||||
|
const newIndex = (index + 1) % getTextPairs().length;
|
||||||
|
setIndex(newIndex);
|
||||||
|
if (dictation)
|
||||||
|
getTTSAudioUrl(
|
||||||
|
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
|
||||||
|
VOICES.find(
|
||||||
|
(v) =>
|
||||||
|
v.locale ===
|
||||||
|
getTextPairs()[newIndex][
|
||||||
|
reverse ? "locale2" : "locale1"
|
||||||
|
],
|
||||||
|
)!.short_name,
|
||||||
|
).then((url) => {
|
||||||
|
load(url);
|
||||||
|
play();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShow(show === "question" ? "answer" : "question");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setIndex(
|
||||||
|
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||||
|
);
|
||||||
|
setShow("question");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReverse = () => setReverse(!reverse);
|
||||||
|
const toggleDictation = () => setDictation(!dictation);
|
||||||
|
const toggleDisorder = () => setDisorder(!disorder);
|
||||||
|
|
||||||
|
const createText = (text: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [text1, text2] = reverse
|
||||||
|
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||||
|
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
{(getTextPairs().length > 0 && (
|
<div className="w-full max-w-2xl">
|
||||||
<>
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
<div className="text-center">
|
{/* 进度指示器 */}
|
||||||
<div
|
<div className="flex justify-center mb-4">
|
||||||
className="text-sm text-gray-500"
|
<button
|
||||||
onClick={() => {
|
onClick={handleIndexClick}
|
||||||
const newIndex = prompt("Input a index number.")?.trim();
|
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
if (
|
|
||||||
newIndex &&
|
|
||||||
isNonNegativeInteger(newIndex) &&
|
|
||||||
parseInt(newIndex) <= textPairs.length &&
|
|
||||||
parseInt(newIndex) > 0
|
|
||||||
) {
|
|
||||||
setIndex(parseInt(newIndex) - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1} / {getTextPairs().length}
|
||||||
{"/" + getTextPairs().length}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
|
|
||||||
{(() => {
|
{/* 文本显示区域 */}
|
||||||
const createText = (text: string) => {
|
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||||
|
{(() => {
|
||||||
|
if (dictation) {
|
||||||
|
if (show === "question") {
|
||||||
return (
|
return (
|
||||||
<div className="text-gray-900 text-xl border-y border-y-gray-200 p-4 md:text-3xl h-[20dvh] overflow-y-auto">
|
<div className="h-full flex items-center justify-center">
|
||||||
{text}
|
<div className="text-gray-400 text-4xl">?</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const [text1, text2] = reverse
|
|
||||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
|
||||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
|
||||||
|
|
||||||
if (dictation) {
|
|
||||||
// dictation
|
|
||||||
if (show === "question") {
|
|
||||||
return createText("");
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{createText(text1)}
|
|
||||||
{createText(text2)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// non-dictation
|
return (
|
||||||
if (show === "question") {
|
<div className="space-y-2">
|
||||||
return createText(text1);
|
{createText(text1)}
|
||||||
} else {
|
<div className="border-t border-gray-200"></div>
|
||||||
return (
|
{createText(text2)}
|
||||||
<>
|
</div>
|
||||||
{createText(text1)}
|
);
|
||||||
{createText(text2)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()}
|
} else {
|
||||||
</div>
|
if (show === "question") {
|
||||||
|
return createText(text1);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{createText(text1)}
|
||||||
|
<div className="border-t border-gray-200"></div>
|
||||||
|
{createText(text2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||||
<LightButton
|
<button
|
||||||
className="w-20"
|
onClick={handleNext}
|
||||||
onClick={async () => {
|
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
||||||
if (show === "answer") {
|
|
||||||
const newIndex = (index + 1) % getTextPairs().length;
|
|
||||||
setIndex(newIndex);
|
|
||||||
if (dictation)
|
|
||||||
getTTSAudioUrl(
|
|
||||||
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
|
|
||||||
VOICES.find(
|
|
||||||
(v) =>
|
|
||||||
v.locale ===
|
|
||||||
getTextPairs()[newIndex][
|
|
||||||
reverse ? "locale2" : "locale1"
|
|
||||||
],
|
|
||||||
)!.short_name,
|
|
||||||
).then((url) => {
|
|
||||||
load(url);
|
|
||||||
play();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShow(show === "question" ? "answer" : "question");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{show === "question" ? t("answer") : t("next")}
|
{show === "question" ? t("answer") : t("next")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={handlePrevious}
|
||||||
setIndex(
|
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
||||||
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
|
||||||
);
|
|
||||||
setShow("question");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("previous")}
|
{t("previous")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleReverse}
|
||||||
setReverse(!reverse);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
reverse
|
||||||
selected={reverse}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("reverse")}
|
{t("reverse")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleDictation}
|
||||||
setDictation(!dictation);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
dictation
|
||||||
selected={dictation}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("dictation")}
|
{t("dictation")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleDisorder}
|
||||||
setDisorder(!disorder);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
disorder
|
||||||
selected={disorder}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("disorder")}
|
{t("disorder")}
|
||||||
</LightButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)) || <p>{t("noTextPairs")}</p>}
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
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 {
|
||||||
@@ -19,6 +18,9 @@ import {
|
|||||||
} from "@/lib/server/services/folderService";
|
} from "@/lib/server/services/folderService";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import CardList from "@/components/ui/CardList";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderProps {
|
||||||
folder: Folder & { total: number };
|
folder: Folder & { total: number };
|
||||||
@@ -27,8 +29,8 @@ interface FolderProps {
|
|||||||
|
|
||||||
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("folders");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
@@ -37,24 +39,23 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<div className="w-10 h-10 rounded-lg bg-linear-to-br from-blue-50 to-blue-100 flex items-center justify-center group-hover:from-blue-100 group-hover:to-blue-200 transition-colors">
|
<div className="shrink-0">
|
||||||
<Fd></Fd>
|
<Fd className="text-gray-600" size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium text-gray-900">
|
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
{t("folderInfo", {
|
{t("folderInfo", {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
totalPairs: folder.total,
|
totalPairs: folder.total,
|
||||||
})}
|
})}
|
||||||
</h3>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-400">#{folder.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -63,7 +64,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
renameFolderById(folder.id, newName).then(refresh);
|
renameFolderById(folder.id, newName).then(refresh);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<FolderPen size={16} />
|
<FolderPen size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -113,40 +114,38 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<PageLayout>
|
||||||
<div className="w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl p-6">
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-light text-gray-900">{t("title")}</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{t("subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const folderName = prompt(t("enterFolderName"));
|
const folderName = prompt(t("enterFolderName"));
|
||||||
if (!folderName) return;
|
if (!folderName) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createFolder({
|
await createFolder({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
user: { connect: { id: userId } },
|
user: { connect: { id: userId } },
|
||||||
});
|
});
|
||||||
await updateFolders();
|
await updateFolders();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<FolderPlus size={18} />
|
<FolderPlus size={18} />
|
||||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-4 max-h-96 overflow-y-auto">
|
<div className="mt-4">
|
||||||
|
<CardList>
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<FolderPlus size={24} className="text-gray-400" />
|
<FolderPlus size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
@@ -164,8 +163,8 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardList>
|
||||||
</div>
|
</div>
|
||||||
</Center>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
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/ui/Container";
|
|
||||||
import {
|
import {
|
||||||
createPair,
|
createPair,
|
||||||
deletePairById,
|
deletePairById,
|
||||||
@@ -12,8 +10,11 @@ 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/ui/buttons/LightButton";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
import GreenButton from "@/components/ui/buttons/GreenButton";
|
||||||
|
import IconButton from "@/components/ui/buttons/IconButton";
|
||||||
|
import CardList from "@/components/ui/CardList";
|
||||||
|
|
||||||
export interface TextPair {
|
export interface TextPair {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,79 +56,73 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<PageLayout>
|
||||||
<Container className="p-6">
|
<div className="mb-6">
|
||||||
<div className="mb-6">
|
<button
|
||||||
<button
|
onClick={router.back}
|
||||||
onClick={router.back}
|
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
>
|
||||||
>
|
<ArrowLeft size={16} />
|
||||||
<ArrowLeft size={16} />
|
<span className="text-sm">{t("back")}</span>
|
||||||
<span className="text-sm">{t("back")}</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-light text-gray-900">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||||
{t("textPairs")}
|
{t("textPairs")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500">
|
||||||
{t("itemsCount", { count: textPairs.length })}
|
{t("itemsCount", { count: textPairs.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LightButton
|
<GreenButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirect(`/memorize?folder_id=${folderId}`);
|
redirect(`/memorize?folder_id=${folderId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("memorize")}
|
{t("memorize")}
|
||||||
</LightButton>
|
</GreenButton>
|
||||||
<button
|
<IconButton
|
||||||
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
onClick={() => {
|
||||||
onClick={() => {
|
setAddModal(true);
|
||||||
setAddModal(true);
|
}}
|
||||||
}}
|
icon={<Plus size={18} className="text-gray-700" />}
|
||||||
>
|
/>
|
||||||
<Plus
|
|
||||||
size={18}
|
|
||||||
className="text-gray-600 hover:cursor-pointer"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardList>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : textPairs.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{textPairs
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((textPair) => (
|
||||||
|
<TextPairCard
|
||||||
|
key={textPair.id}
|
||||||
|
textPair={textPair}
|
||||||
|
onDel={() => {
|
||||||
|
deletePairById(textPair.id);
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
refreshTextPairs={refreshTextPairs}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
|
||||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
|
||||||
</div>
|
|
||||||
) : textPairs.length === 0 ? (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-gray-100">
|
|
||||||
{textPairs
|
|
||||||
.toSorted((a, b) => a.id - b.id)
|
|
||||||
.map((textPair) => (
|
|
||||||
<TextPairCard
|
|
||||||
key={textPair.id}
|
|
||||||
textPair={textPair}
|
|
||||||
onDel={() => {
|
|
||||||
deletePairById(textPair.id);
|
|
||||||
refreshTextPairs();
|
|
||||||
}}
|
|
||||||
refreshTextPairs={refreshTextPairs}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
<AddTextPairModal
|
<AddTextPairModal
|
||||||
isOpen={openAddModal}
|
isOpen={openAddModal}
|
||||||
onClose={() => setAddModal(false)}
|
onClose={() => setAddModal(false)}
|
||||||
@@ -151,6 +146,6 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/components/ui/CardList.tsx
Normal file
12
src/components/ui/CardList.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
interface CardListProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardList({ children, className = "" }: CardListProps) {
|
||||||
|
return (
|
||||||
|
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/ui/PageHeader.tsx
Normal file
17
src/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-gray-500">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/ui/PageLayout.tsx
Normal file
16
src/components/ui/PageLayout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageLayout({ children, className = "" }: PageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}>
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/ui/buttons/GrayButton.tsx
Normal file
34
src/components/ui/buttons/GrayButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import PlainButton, { ButtonType } from "./PlainButton";
|
||||||
|
|
||||||
|
interface GrayButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
type?: ButtonType;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GrayButton({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
selected = false,
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
}: GrayButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlainButton
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
|
selected
|
||||||
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
} ${className}`}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlainButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ui/buttons/GreenButton.tsx
Normal file
28
src/components/ui/buttons/GreenButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import PlainButton, { ButtonType } from "./PlainButton";
|
||||||
|
|
||||||
|
interface GreenButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
type?: ButtonType;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GreenButton({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
}: GreenButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlainButton
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-4 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors text-sm font-medium ${className}`}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlainButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ui/buttons/IconButton.tsx
Normal file
28
src/components/ui/buttons/IconButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import PlainButton, { ButtonType } from "./PlainButton";
|
||||||
|
|
||||||
|
interface IconButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
type?: ButtonType;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IconButton({
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlainButton
|
||||||
|
onClick={onClick}
|
||||||
|
className={`p-2 bg-gray-200 rounded-full hover:bg-gray-300 transition-colors ${className}`}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</PlainButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user