今天做了好多工作啊
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-12-04 21:07:54 +08:00
parent fcc20fc2e0
commit 41005a4aac
27 changed files with 733 additions and 6294 deletions

View File

@@ -26,8 +26,8 @@ steps:
DATABASE_URL: DATABASE_URL:
from_secret: database_url from_secret: database_url
commands: commands:
- npm install prisma - npm install -g prisma
- npx prisma migrate deploy - prisma migrate deploy
- name: deploy - name: deploy
image: appleboy/drone-ssh image: appleboy/drone-ssh

View File

@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack --experimental-https", "dev": "next dev --turbopack --experimental-https",
"build": "next build --turbopack", "build": "next build --turbopack",
@@ -10,25 +11,27 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"edge-tts-universal": "^1.3.2", "edge-tts-universal": "^1.3.3",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"next": "15.5.3", "next": "15.5.3",
"next-auth": "^4.24.13", "next-auth": "5.0.0-beta.30",
"next-intl": "^4.5.2", "next-intl": "^4.5.8",
"pg": "^8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"unstorage": "^1.17.2", "unstorage": "^1.17.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.19.25", "@types/node": "^20.19.25",
"@types/react": "^19.2.4", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "15.5.3", "eslint-config-next": "15.5.3",

6429
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
-- CreateTable
CREATE TABLE "text_pair" (
"id" SERIAL NOT NULL,
"locale1" VARCHAR(10) NOT NULL,
"locale2" VARCHAR(10) NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "text_pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folder" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"owner" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "text_pair" ADD CONSTRAINT "fk_text_pairs_folder" FOREIGN KEY ("folder_id") REFERENCES "folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,71 @@
/*
Warnings:
- You are about to drop the `folder` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `text_pair` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "text_pair" DROP CONSTRAINT "fk_text_pairs_folder";
-- DropTable
DROP TABLE "folder";
-- DropTable
DROP TABLE "text_pair";
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"locale1" VARCHAR(10) NOT NULL,
"locale2" VARCHAR(10) NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"ipa1" TEXT,
"ipa2" TEXT,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folders" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"user_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- AddForeignKey
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -5,27 +5,49 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
} }
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. model Pair {
model text_pair { id Int @id @default(autoincrement())
id Int @id(map: "text_pairs_pkey") @default(autoincrement()) locale1 String @db.VarChar(10)
locale1 String @db.VarChar(10) locale2 String @db.VarChar(10)
locale2 String @db.VarChar(10) text1 String
text1 String text2 String
text2 String ipa1 String?
folder_id Int ipa2 String?
created_at DateTime? @default(now()) @db.Timestamptz(6) folderId Int @map("folder_id")
updated_at DateTime? @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at")
folders folder @relation(fields: [folder_id], references: [id], onDelete: Cascade, map: "fk_text_pairs_folder") updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, locale1, locale2, text1])
@@index([folderId])
@@map("pairs")
} }
model folder { model Folder {
id Int @id(map: "folders_pkey") @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
owner String userId Int @map("user_id")
created_at DateTime? @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at")
updated_at DateTime? @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at")
text_pair text_pair[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folders Folder[]
@@map("users")
} }

View File

@@ -1,15 +1,15 @@
"use client"; "use client";
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { folder } from "../../../../generated/prisma/client";
import { Folder } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Center } from "@/components/Center"; import { Center } from "@/components/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 as Fd } from "lucide-react";
interface FolderSelectorProps { interface FolderSelectorProps {
folders: (folder & { total_pairs: number })[]; folders: (Folder & { total: number })[];
} }
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => { const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
@@ -41,13 +41,13 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
} }
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50" className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
> >
<Folder /> <Fd />
<div className="flex-1 flex gap-2"> <div className="flex-1 flex gap-2">
<span className="group-hover:text-blue-500"> <span className="group-hover:text-blue-500">
{t("folderInfo", { {t("folderInfo", {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
count: folder.total_pairs, count: folder.total,
})} })}
</span> </span>
</div> </div>

View File

@@ -1,8 +1,5 @@
"use client"; "use client";
import { Center } from "@/components/Center";
import { text_pair } from "../../../../generated/prisma/browser";
import Container from "@/components/cards/Container";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
@@ -11,13 +8,14 @@ import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({ const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
}); });
interface MemorizeProps { interface MemorizeProps {
textPairs: text_pair[]; textPairs: Pair[];
} }
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => { const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
@@ -29,7 +27,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const [show, setShow] = useState<"question" | "answer">("question"); const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [disorderedTextPairs, setDisorderedTextPairs] = useState<text_pair[]>( const [disorderedTextPairs, setDisorderedTextPairs] = useState<Pair[]>(
[], [],
); );
@@ -66,7 +64,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
{index + 1} {index + 1}
{"/" + getTextPairs().length} {"/" + getTextPairs().length}
</div> </div>
<div className="h-[40dvh] px-16"> <div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
{(() => { {(() => {
const createText = (text: string) => { const createText = (text: string) => {
return ( return (

View File

@@ -1,24 +1,24 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByOwner, getFoldersWithTotalPairsByUserId,
getOwnerByFolderId, getUserIdByFolderId,
} from "@/lib/actions/services/folderService"; } from "@/lib/actions/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getTextPairsByFolderId } from "@/lib/actions/services/textPairService"; import { getPairsByFolderId } from "@/lib/actions/services/pairService";
import { auth } from "@/auth";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ folder_id?: string }>; searchParams: Promise<{ folder_id?: string }>;
}) { }) {
const session = await getServerSession(); const session = await auth();
const username = session?.user?.name; const userId = session?.user?.id;
const t = await getTranslations("memorize.page"); const t = await getTranslations("memorize.page");
const tParam = (await searchParams).folder_id; const tParam = (await searchParams).folder_id;
@@ -28,23 +28,26 @@ export default async function MemorizePage({
: null : null
: null; : null;
if (!username) if (!userId) {
redirect( redirect(
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`, `/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
); );
}
const uid = Number(userId);
if (!folder_id) { if (!folder_id) {
return ( return (
<FolderSelector <FolderSelector
folders={await getFoldersWithTotalPairsByOwner(username)} folders={await getFoldersWithTotalPairsByUserId(uid)}
/> />
); );
} }
const owner = await getOwnerByFolderId(folder_id); const owner = await getUserIdByFolderId(folder_id);
if (owner !== username) { if (owner !== uid) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }
return <Memorize textPairs={await getTextPairsByFolderId(folder_id)} />; return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
} }

View File

@@ -4,10 +4,10 @@ import { TranslationHistorySchema } from "@/lib/interfaces";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Dispatch, useEffect, useState } from "react"; import { Dispatch, useEffect, useState } from "react";
import z from "zod"; import z from "zod";
import { folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByOwner } from "@/lib/actions/services/folderService"; import { getFoldersByUserId } from "@/lib/actions/services/folderService";
import { Folder } from "lucide-react"; import { Folder as Fd } from "lucide-react";
import { createTextPair } from "@/lib/actions/services/textPairService"; import { createPair } from "@/lib/actions/services/pairService";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -18,13 +18,13 @@ interface AddToFolderProps {
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => { const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const session = useSession(); const session = useSession();
const [folders, setFolders] = useState<folder[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder"); const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const username = session.data!.user!.name as string; const userId = Number(session.data!.user!.id);
getFoldersByOwner(username) getFoldersByUserId(userId)
.then(setFolders) .then(setFolders)
.then(() => setLoading(false)); .then(() => setLoading(false));
}, [session.data]); }, [session.data]);
@@ -50,12 +50,12 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
key={folder.id} key={folder.id}
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200" className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
onClick={() => { onClick={() => {
createTextPair({ createPair({
text1: item.text1, text1: item.text1,
text2: item.text2, text2: item.text2,
locale1: item.locale1, locale1: item.locale1,
locale2: item.locale2, locale2: item.locale2,
folders: { folder: {
connect: { connect: {
id: folder.id, id: folder.id,
}, },
@@ -70,7 +70,7 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
}); });
}} }}
> >
<Folder /> <Fd />
{t("folderInfo", { id: folder.id, name: folder.name })} {t("folderInfo", { id: folder.id, name: folder.name })}
</button> </button>
))) || <div>{t("noFolders")}</div>} ))) || <div>{t("noFolders")}</div>}

View File

@@ -1,29 +1,29 @@
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByOwner } from "@/lib/actions/services/folderService"; import { getFoldersByUserId } from "@/lib/actions/services/folderService";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { Folder } from "lucide-react"; import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps { interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void; setSelectedFolderId: (id: number) => void;
username: string; userId: number;
cancel: () => void; cancel: () => void;
} }
const FolderSelector: React.FC<FolderSelectorProps> = ({ const FolderSelector: React.FC<FolderSelectorProps> = ({
setSelectedFolderId, setSelectedFolderId,
username, userId,
cancel, cancel,
}) => { }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [folders, setFolders] = useState<folder[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
useEffect(() => { useEffect(() => {
getFoldersByOwner(username) getFoldersByUserId(userId)
.then(setFolders) .then(setFolders)
.then(() => setLoading(false)); .then(() => setLoading(false));
}, [username]); }, [userId]);
return ( return (
<div <div
@@ -41,7 +41,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({
key={folder.id} key={folder.id}
onClick={() => setSelectedFolderId(folder.id)} onClick={() => setSelectedFolderId(folder.id)}
> >
<Folder /> <Fd />
{folder.id}. {folder.name} {folder.id}. {folder.name}
</button> </button>
))} ))}

View File

@@ -21,7 +21,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { createTextPair } from "@/lib/actions/services/textPairService"; import { createPair } from "@/lib/actions/services/pairService";
import { shallowEqual } from "@/lib/utils"; import { shallowEqual } from "@/lib/utils";
export default function TranslatorPage() { export default function TranslatorPage() {
@@ -109,12 +109,12 @@ export default function TranslatorPage() {
}), }),
); );
if (autoSave && autoSaveFolderId) { if (autoSave && autoSaveFolderId) {
createTextPair({ createPair({
text1: llmres.text1, text1: llmres.text1,
text2: llmres.text2, text2: llmres.text2,
locale1: llmres.locale1, locale1: llmres.locale1,
locale2: llmres.locale2, locale2: llmres.locale2,
folders: { folder: {
connect: { connect: {
id: autoSaveFolderId, id: autoSaveFolderId,
}, },
@@ -128,10 +128,10 @@ export default function TranslatorPage() {
.catch((error) => { .catch((error) => {
toast.error( toast.error(
llmres.text1 + llmres.text1 +
"保存到文件夹" + "保存到文件夹" +
autoSaveFolderId + autoSaveFolderId +
"失败:" + "失败:" +
error.message, error.message,
); );
}); });
} }
@@ -364,7 +364,7 @@ export default function TranslatorPage() {
)} )}
{autoSave && !autoSaveFolderId && ( {autoSave && !autoSaveFolderId && (
<FolderSelector <FolderSelector
username={session.data!.user!.name as string} userId={Number(session.data!.user!.id)}
cancel={() => setAutoSave(false)} cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)} setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/> />

View File

@@ -1,15 +1,3 @@
import NextAuth, { AuthOptions } from "next-auth"; import { handlers } from "../../../../auth";
import GithubProvider from "next-auth/providers/github";
export const authOptions: AuthOptions = { export const { GET, POST } = handlers;
providers: [
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -2,7 +2,7 @@
import { import {
ChevronRight, ChevronRight,
Folder, Folder as Fd,
FolderPen, FolderPen,
FolderPlus, FolderPlus,
Trash2, Trash2,
@@ -10,18 +10,18 @@ import {
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";
import { folder } from "../../../generated/prisma/browser"; import { Folder } from "../../../generated/prisma/browser";
import { import {
createFolder, createFolder,
deleteFolderById, deleteFolderById,
getFoldersWithTotalPairsByOwner, getFoldersWithTotalPairsByUserId,
renameFolderById, renameFolderById,
} from "@/lib/actions/services/folderService"; } from "@/lib/actions/services/folderService";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
interface FolderProps { interface FolderProps {
folder: folder & { total_pairs: number }; folder: Folder & { total: number };
refresh: () => void; refresh: () => void;
} }
@@ -38,7 +38,7 @@ 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="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">
<Folder></Folder> <Fd></Fd>
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -46,7 +46,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
{t("folderInfo", { {t("folderInfo", {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
totalPairs: folder.total_pairs, totalPairs: folder.total,
})} })}
</h3> </h3>
</div> </div>
@@ -85,16 +85,16 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
); );
}; };
export default function FoldersClient({ username }: { username: string }) { export default function FoldersClient({ userId }: { userId: number }) {
const t = useTranslations("folders"); const t = useTranslations("folders");
const [folders, setFolders] = useState<(folder & { total_pairs: number })[]>( const [folders, setFolders] = useState<(Folder & { total: number })[]>(
[], [],
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getFoldersWithTotalPairsByOwner(username) getFoldersWithTotalPairsByUserId(userId)
.then((folders) => { .then((folders) => {
setFolders(folders); setFolders(folders);
setLoading(false); setLoading(false);
@@ -103,11 +103,11 @@ export default function FoldersClient({ username }: { username: string }) {
console.error(error); console.error(error);
toast.error("加载出错,请重试。"); toast.error("加载出错,请重试。");
}); });
}, [username]); }, [userId]);
const updateFolders = async () => { const updateFolders = async () => {
try { try {
const updatedFolders = await getFoldersWithTotalPairsByOwner(username); const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
setFolders(updatedFolders); setFolders(updatedFolders);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -129,7 +129,7 @@ export default function FoldersClient({ username }: { username: string }) {
try { try {
await createFolder({ await createFolder({
name: folderName, name: folderName,
owner: username, user: { connect: { id: userId } },
}); });
await updateFolders(); await updateFolders();
} finally { } finally {

View File

@@ -6,10 +6,10 @@ 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/cards/Container";
import { import {
createTextPair, createPair,
deleteTextPairById, deletePairById,
getTextPairsByFolderId, getPairsByFolderId,
} from "@/lib/actions/services/textPairService"; } from "@/lib/actions/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/buttons/LightButton";
@@ -34,7 +34,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const fetchTextPairs = async () => { const fetchTextPairs = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await getTextPairsByFolderId(folderId); const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]); setTextPairs(data as TextPair[]);
} catch (error) { } catch (error) {
console.error("Failed to fetch text pairs:", error); console.error("Failed to fetch text pairs:", error);
@@ -47,7 +47,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const refreshTextPairs = async () => { const refreshTextPairs = async () => {
try { try {
const data = await getTextPairsByFolderId(folderId); const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]); setTextPairs(data as TextPair[]);
} catch (error) { } catch (error) {
console.error("Failed to fetch text pairs:", error); console.error("Failed to fetch text pairs:", error);
@@ -118,7 +118,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
key={textPair.id} key={textPair.id}
textPair={textPair} textPair={textPair}
onDel={() => { onDel={() => {
deleteTextPairById(textPair.id); deletePairById(textPair.id);
refreshTextPairs(); refreshTextPairs();
}} }}
refreshTextPairs={refreshTextPairs} refreshTextPairs={refreshTextPairs}
@@ -137,12 +137,12 @@ export default function InFolder({ folderId }: { folderId: number }) {
locale1: string, locale1: string,
locale2: string, locale2: string,
) => { ) => {
await createTextPair({ await createPair({
text1: text1, text1: text1,
text2: text2, text2: text2,
locale1: locale1, locale1: locale1,
locale2: locale2, locale2: locale2,
folders: { folder: {
connect: { connect: {
id: folderId, id: folderId,
}, },

View File

@@ -1,10 +1,10 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { updateTextPairById } from "@/lib/actions/services/textPairService"; import { updatePairById } from "@/lib/actions/services/pairService";
import { useState } from "react"; import { useState } from "react";
import { text_pairUpdateInput } from "../../../../generated/prisma/models";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { PairUpdateInput } from "../../../../generated/prisma/models";
interface TextPairCardProps { interface TextPairCardProps {
textPair: TextPair; textPair: TextPair;
@@ -66,8 +66,8 @@ export default function TextPairCard({
<UpdateTextPairModal <UpdateTextPairModal
isOpen={openUpdateModal} isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)} onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: text_pairUpdateInput) => { onUpdate={async (id: number, data: PairUpdateInput) => {
await updateTextPairById(id, data); await updatePairById(id, data);
setOpenUpdateModal(false); setOpenUpdateModal(false);
refreshTextPairs(); refreshTextPairs();
}} }}

View File

@@ -2,7 +2,7 @@ import LightButton from "@/components/buttons/LightButton";
import Input from "@/components/Input"; import Input from "@/components/Input";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
import { text_pairUpdateInput } from "../../../../generated/prisma/models"; import { PairUpdateInput } from "../../../../generated/prisma/models";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -10,7 +10,7 @@ interface UpdateTextPairModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
textPair: TextPair; textPair: TextPair;
onUpdate: (id: number, tp: text_pairUpdateInput) => void; onUpdate: (id: number, tp: PairUpdateInput) => void;
} }
export default function UpdateTextPairModal({ export default function UpdateTextPairModal({

View File

@@ -1,24 +1,23 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder"; import InFolder from "./InFolder";
import { getOwnerByFolderId } from "@/lib/actions/services/folderService"; import { getUserIdByFolderId } from "@/lib/actions/services/folderService";
import { auth } from "@/auth";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
}: { }: {
params: Promise<{ folder_id: number }>; params: Promise<{ folder_id: number }>;
}) { }) {
const session = await getServerSession(); const session = await auth();
const { folder_id } = await params; const { folder_id } = await params;
const id = Number(folder_id);
const t = await getTranslations("folder_id"); const t = await getTranslations("folder_id");
if (!id) { if (!folder_id) {
redirect("/folders"); redirect("/folders");
} }
if (!session?.user?.name) redirect(`/login?redirect=/folders/${id}`); if (!session?.user?.id) redirect(`/login?redirect=/folders/${folder_id}`);
if ((await getOwnerByFolderId(id)) !== session.user.name) { if ((await getUserIdByFolderId(Number(folder_id))) !== Number(session.user.id)) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }
return <InFolder folderId={id} />; return <InFolder folderId={Number(folder_id)} />;
} }

View File

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

30
src/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { createUserIfNotExists, getUserIdByEmail } from "./lib/actions/services/userService";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
async signIn({ user }) {
if (!user.email) return false;
await createUserIfNotExists(user.email, user.name);
return true
},
async session({ session }) {
if (session.user?.email) {
const userId = await getUserIdByEmail(session.user.email);
if (userId) {
session.user.id = userId.toString();
}
}
return session;
},
},
});

View File

@@ -1,15 +1,12 @@
"use server"; "use server";
import { import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
folderCreateInput,
folderUpdateInput,
} from "../../../../generated/prisma/models";
import prisma from "../../db"; import prisma from "../../db";
export async function getFoldersByOwner(owner: string) { export async function getFoldersByUserId(userId: number) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { where: {
owner: owner, userId: userId,
}, },
}); });
return folders; return folders;
@@ -26,27 +23,23 @@ export async function renameFolderById(id: number, newName: string) {
}); });
} }
export async function getFoldersWithTotalPairsByOwner(owner: string) { export async function getFoldersWithTotalPairsByUserId(userId: number) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { where: { userId },
owner: owner,
},
include: { include: {
text_pair: { _count: {
select: { select: { pairs: true },
id: true,
},
}, },
}, },
}); });
return folders.map((folder) => ({ return folders.map(folder => ({
...folder, ...folder,
total_pairs: folder.text_pair.length, total: folder._count?.pairs ?? 0,
})); }));
} }
export async function createFolder(folder: folderCreateInput) { export async function createFolder(folder: FolderCreateInput) {
await prisma.folder.create({ await prisma.folder.create({
data: folder, data: folder,
}); });
@@ -60,7 +53,7 @@ export async function deleteFolderById(id: number) {
}); });
} }
export async function updateFolderById(id: number, data: folderUpdateInput) { export async function updateFolderById(id: number, data: FolderUpdateInput) {
await prisma.folder.update({ await prisma.folder.update({
where: { where: {
id: id, id: id,
@@ -69,11 +62,11 @@ export async function updateFolderById(id: number, data: folderUpdateInput) {
}); });
} }
export async function getOwnerByFolderId(id: number) { export async function getUserIdByFolderId(id: number) {
const folder = await prisma.folder.findUnique({ const folder = await prisma.folder.findUnique({
where: { where: {
id: id, id: id,
}, },
}); });
return folder?.owner; return folder?.userId;
} }

View File

@@ -0,0 +1,48 @@
"use server";
import { PairCreateInput, PairUpdateInput } from "../../../../generated/prisma/models";
import prisma from "../../db";
export async function createPair(data: PairCreateInput) {
await prisma.pair.create({
data: data,
});
}
export async function deletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function updatePairById(
id: number,
data: PairUpdateInput,
) {
await prisma.pair.update({
where: {
id: id,
},
data: data,
});
}
export async function getPairCountByFolderId(folderId: number) {
const count = await prisma.pair.count({
where: {
folderId: folderId,
},
});
return count;
}
export async function getPairsByFolderId(folderId: number) {
const textPairs = await prisma.pair.findMany({
where: {
folderId: folderId,
},
});
return textPairs;
}

View File

@@ -1,51 +0,0 @@
"use server";
import {
text_pairCreateInput,
text_pairUpdateInput,
} from "../../../../generated/prisma/models";
import prisma from "../../db";
export async function createTextPair(data: text_pairCreateInput) {
await prisma.text_pair.create({
data: data,
});
}
export async function deleteTextPairById(id: number) {
await prisma.text_pair.delete({
where: {
id: id,
},
});
}
export async function updateTextPairById(
id: number,
data: text_pairUpdateInput,
) {
await prisma.text_pair.update({
where: {
id: id,
},
data: data,
});
}
export async function getTextPairCountByFolderId(folderId: number) {
const count = await prisma.text_pair.count({
where: {
folder_id: folderId,
},
});
return count;
}
export async function getTextPairsByFolderId(folderId: number) {
const textPairs = await prisma.text_pair.findMany({
where: {
folder_id: folderId,
},
});
return textPairs;
}

View File

@@ -0,0 +1,28 @@
import prisma from "@/lib/db";
import { UserCreateInput } from "../../../../generated/prisma/models";
export async function createUserIfNotExists(email: string, name?: string | null) {
const user = await prisma.user.upsert({
where: {
email: email,
},
update: {},
create: {
email: email,
name: name || "New User",
} as UserCreateInput,
});
return user;
}
export async function getUserIdByEmail(email: string) {
const user = await prisma.user.findUnique({
where: {
email: email,
},
select: {
id: true,
},
});
return user ? user.id : null;
}

View File

@@ -1,4 +1,10 @@
import { PrismaClient } from "../../generated/prisma/client"; import { PrismaClient } from "../../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const prisma = new PrismaClient(); const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
const prisma = new PrismaClient({
adapter: adapter,
});
export default prisma; export default prisma;

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "es2023",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -19,9 +23,18 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
"exclude": [
"node_modules"
]
} }