Compare commits
3 Commits
467232457a
...
e845c4abb7
| Author | SHA1 | Date | |
|---|---|---|---|
| e845c4abb7 | |||
| 881d9ca921 | |||
| db96b86e65 |
@@ -3,10 +3,10 @@ ZHIPU_API_KEY=
|
||||
ZHIPU_MODEL_NAME=
|
||||
|
||||
// Auth
|
||||
AUTH_SECRET=
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
NEXTAUTH_URL=
|
||||
|
||||
// Database
|
||||
DATABASE_URL=
|
||||
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": null,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
])
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
export default eslintConfig
|
||||
@@ -105,7 +105,7 @@
|
||||
"title": "learn-languages",
|
||||
"about": "About",
|
||||
"sourceCode": "GitHub",
|
||||
"login": "Login",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"title": "学语言",
|
||||
"about": "关于",
|
||||
"sourceCode": "源码",
|
||||
"login": "登录",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹"
|
||||
},
|
||||
|
||||
25
package.json
25
package.json
@@ -14,29 +14,33 @@
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"edge-tts-universal": "^1.3.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "16.0.7",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.8",
|
||||
"next-intl": "^4.5.8",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"unstorage": "^1.17.3",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.6",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"prisma": "^6.19.0",
|
||||
"eslint-config-next": "16.0.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.1.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
@@ -44,6 +48,9 @@
|
||||
"overrides": {
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@prisma/client"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1834
pnpm-lock.yaml
generated
1834
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ export default defineConfig({
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
@@ -29,7 +30,7 @@ model Pair {
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
userId Int @map("user_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -41,13 +42,65 @@ model Folder {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
id String @id
|
||||
name String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
folders Folder[]
|
||||
|
||||
@@map("users")
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([token])
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([identifier])
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||
import { Pair } from "../../../../generated/prisma/browser";
|
||||
|
||||
const myFont = localFont({
|
||||
@@ -27,20 +27,16 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
const [show, setShow] = useState<"question" | "answer">("question");
|
||||
const { load, play } = useAudioPlayer();
|
||||
|
||||
const [disorderedTextPairs, setDisorderedTextPairs] = useState<Pair[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDisorderedTextPairs(textPairs.toSorted(() => Math.random() - 0.5));
|
||||
}, [textPairs]);
|
||||
|
||||
const getTextPairs = () => {
|
||||
if (disorder) {
|
||||
return disorderedTextPairs;
|
||||
if (textPairs.length === 0) {
|
||||
return <p>{t("noTextPairs")}</p>;
|
||||
}
|
||||
return textPairs.toSorted((a, b) => a.id - b.id);
|
||||
};
|
||||
|
||||
const rng = new SeededRandom(textPairs[0].folderId);
|
||||
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||
|
||||
textPairs.sort((a, b) => a.id - b.id);
|
||||
|
||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
getFoldersWithTotalPairsByUserId,
|
||||
getUserIdByFolderId,
|
||||
} from "@/lib/actions/services/folderService";
|
||||
} from "@/lib/server/services/folderService";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import Memorize from "./Memorize";
|
||||
import { getPairsByFolderId } from "@/lib/actions/services/pairService";
|
||||
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string }>;
|
||||
searchParams: Promise<{ folder_id?: string; }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const tParam = (await searchParams).folder_id;
|
||||
|
||||
if (!session) {
|
||||
redirect(
|
||||
`/login?redirect=/memorize${(await searchParams).folder_id
|
||||
? `?folder_id=${tParam}`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const t = await getTranslations("memorize.page");
|
||||
|
||||
const tParam = (await searchParams).folder_id;
|
||||
const folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!userId) {
|
||||
redirect(
|
||||
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const uid = Number(userId);
|
||||
|
||||
if (!folder_id) {
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={await getFoldersWithTotalPairsByUserId(uid)}
|
||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await getUserIdByFolderId(folder_id);
|
||||
if (owner !== uid) {
|
||||
if (owner !== session.user.id) {
|
||||
return <p>{t("unauthorized")}</p>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||
import SubtitleDisplay from "./SubtitleDisplay";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||
@@ -20,7 +20,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
const [spanText, setSpanText] = useState<string>("");
|
||||
const [subtitle, setSubtitle] = useState<string>("");
|
||||
const parsedSrtRef = useRef<
|
||||
{ start: number; end: number; text: string }[] | null
|
||||
{ start: number; end: number; text: string; }[] | null
|
||||
>(null);
|
||||
const rafldRef = useRef<number>(0);
|
||||
const ready = useRef({
|
||||
@@ -31,7 +31,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
},
|
||||
});
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!videoUrl) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
@@ -42,7 +42,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
video.pause();
|
||||
}
|
||||
setIsPlaying(!video.paused);
|
||||
}
|
||||
}, [videoRef, videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { genIPA, genLocale } from "@/lib/actions/translatorActions";
|
||||
import { genIPA, genLocale } from "@/lib/server/translatorActions";
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text_speaker");
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Container from "@/components/cards/Container";
|
||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Dispatch, useEffect, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/actions/services/folderService";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { createPair } from "@/lib/actions/services/pairService";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface AddToFolderProps {
|
||||
item: z.infer<typeof TranslationHistorySchema>;
|
||||
@@ -17,19 +19,21 @@ interface AddToFolderProps {
|
||||
}
|
||||
|
||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||
const session = useSession();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const t = useTranslations("translator.add_to_folder");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = Number(session.data!.user!.id);
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
getFoldersByUserId(userId)
|
||||
.then(setFolders)
|
||||
.then(() => setLoading(false));
|
||||
}, [session.data]);
|
||||
}, [session]);
|
||||
|
||||
if (session.status !== "authenticated") {
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||
<Container className="p-6">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Container from "@/components/cards/Container";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/actions/services/folderService";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
setSelectedFolderId: (id: number) => void;
|
||||
userId: number;
|
||||
userId: string;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,25 +10,23 @@ import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import AddToFolder from "./AddToFolder";
|
||||
import {
|
||||
genIPA,
|
||||
genLocale,
|
||||
genTranslation,
|
||||
} from "@/lib/actions/translatorActions";
|
||||
} from "@/lib/server/translatorActions";
|
||||
import { toast } from "sonner";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createPair } from "@/lib/actions/services/pairService";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { shallowEqual } from "@/lib/utils";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const [lang, setLang] = useState<string>("chinese");
|
||||
const [tresult, setTresult] = useState<string>("");
|
||||
@@ -38,7 +36,7 @@ export default function TranslatorPage() {
|
||||
const { load, play } = useAudioPlayer();
|
||||
const [history, setHistory] = useState<
|
||||
z.infer<typeof TranslationHistorySchema>[]
|
||||
>(tlso.get());
|
||||
>([]);
|
||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||
typeof TranslationHistorySchema
|
||||
@@ -49,6 +47,11 @@ export default function TranslatorPage() {
|
||||
});
|
||||
const [autoSave, setAutoSave] = useState(false);
|
||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(tlso.get());
|
||||
}, []);
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
@@ -306,7 +309,7 @@ export default function TranslatorPage() {
|
||||
checked={autoSave}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
if (checked === true && !(session.status === "authenticated")) {
|
||||
if (checked === true && !session) {
|
||||
toast.warning("Please login to enable auto-save");
|
||||
return;
|
||||
}
|
||||
@@ -364,7 +367,7 @@ export default function TranslatorPage() {
|
||||
)}
|
||||
{autoSave && !autoSaveFolderId && (
|
||||
<FolderSelector
|
||||
userId={Number(session.data!.user!.id)}
|
||||
userId={session!.user.id as string}
|
||||
cancel={() => setAutoSave(false)}
|
||||
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
||||
/>
|
||||
|
||||
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from "../../../../auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
deleteFolderById,
|
||||
getFoldersWithTotalPairsByUserId,
|
||||
renameFolderById,
|
||||
} from "@/lib/actions/services/folderService";
|
||||
} from "@/lib/server/services/folderService";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -85,7 +85,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function FoldersClient({ userId }: { userId: number }) {
|
||||
export default function FoldersClient({ userId }: { userId: string }) {
|
||||
const t = useTranslations("folders");
|
||||
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
||||
[],
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createPair,
|
||||
deletePairById,
|
||||
getPairsByFolderId,
|
||||
} from "@/lib/actions/services/pairService";
|
||||
} from "@/lib/server/services/pairService";
|
||||
import AddTextPairModal from "./AddTextPairModal";
|
||||
import TextPairCard from "./TextPairCard";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { TextPair } from "./InFolder";
|
||||
import { updatePairById } from "@/lib/actions/services/pairService";
|
||||
import { updatePairById } from "@/lib/server/services/pairService";
|
||||
import { useState } from "react";
|
||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import InFolder from "./InFolder";
|
||||
import { getUserIdByFolderId } from "@/lib/actions/services/folderService";
|
||||
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
export default async function FoldersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ folder_id: number }>;
|
||||
params: Promise<{ folder_id: number; }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { folder_id } = await params;
|
||||
const t = await getTranslations("folder_id");
|
||||
|
||||
if (!folder_id) {
|
||||
redirect("/folders");
|
||||
}
|
||||
if (!session?.user?.id) redirect(`/login?redirect=/folders/${folder_id}`);
|
||||
if ((await getUserIdByFolderId(Number(folder_id))) !== Number(session.user.id)) {
|
||||
if (!session) redirect(`/login?redirect=/folders/${folder_id}`);
|
||||
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
||||
return <p>{t("unauthorized")}</p>;
|
||||
}
|
||||
return <InFolder folderId={Number(folder_id)} />;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { auth } from "@/auth";
|
||||
import FoldersClient from "./FoldersClient";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect(`/login?redirect=/folders`);
|
||||
return <FoldersClient userId={Number(session.user.id)} />;
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
if (!session) redirect(`/signin?redirect=/folders`);
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import type { Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import SessionWrapper from "@/components/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
@@ -22,7 +21,6 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<SessionWrapper>
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
@@ -32,6 +30,5 @@ export default async function RootLayout({
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
</SessionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Center } from "@/components/Center";
|
||||
import IMAGES from "@/config/images";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function LoginPage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("login");
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "authenticated") {
|
||||
router.push(searchParams.get("redirect") || "/");
|
||||
}
|
||||
}, [session.status, router, searchParams]);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
{session.status === "loading" ? (
|
||||
<div>{t("loading")}</div>
|
||||
) : (
|
||||
<LightButton
|
||||
className="flex flex-row p-2 gap-2"
|
||||
onClick={() => signIn("github")}
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark}
|
||||
alt="GitHub Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span>{t("githubLogin")}</span>
|
||||
</LightButton>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,13 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("home");
|
||||
function TopArea() {
|
||||
return (
|
||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface LinkAreaProps {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||
return (
|
||||
<Link
|
||||
@@ -35,10 +22,27 @@ export default function HomePage() {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
function LinkGrid() {
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("home");
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
||||
<LinkArea
|
||||
<>
|
||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"><LinkArea
|
||||
href="/translator"
|
||||
name={t("translator.name")}
|
||||
description={t("translator.description")}
|
||||
@@ -75,29 +79,6 @@ export default function HomePage() {
|
||||
color="#cab48a"
|
||||
></LinkArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Fortune() {
|
||||
return (
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Explore() {
|
||||
return (
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopArea></TopArea>
|
||||
<Fortune></Fortune>
|
||||
<Explore></Explore>
|
||||
<LinkGrid></LinkGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/app/profile/LogoutButton.tsx
Normal file
20
src/app/profile/LogoutButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const t = useTranslations("profile");
|
||||
const router = useRouter();
|
||||
return <LightButton onClick={async () => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login?redirect=/profile");
|
||||
}
|
||||
}
|
||||
});
|
||||
}}> {t("logout")}</LightButton >;
|
||||
}
|
||||
@@ -1,43 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useEffect } from "react";
|
||||
import { Center } from "@/components/Center";
|
||||
import Container from "@/components/cards/Container";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { auth } from "@/auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
||||
export default function MePage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("profile");
|
||||
export default async function ProfilePage() {
|
||||
const t = await getTranslations("profile");
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status !== "authenticated") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/signin?redirect=/profile");
|
||||
}
|
||||
}, [session.status, router, pathname]);
|
||||
|
||||
console.log(JSON.stringify(session, null, 2));
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6">
|
||||
<h1>{t("myProfile")}</h1>
|
||||
{(session.data?.user?.image as string) && (
|
||||
{(session.user.image) && (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
alt="User Avatar"
|
||||
src={session.data?.user?.image as string}
|
||||
src={session.user.image as string}
|
||||
className="rounded-4xl"
|
||||
></Image>
|
||||
)}
|
||||
<p>{session.data?.user?.name}</p>
|
||||
<p>{t("email", { email: session.data!.user!.email as string })}</p>
|
||||
<LightButton onClick={signOut}>{t("logout")}</LightButton>
|
||||
<p>{session.user.name}</p>
|
||||
<p>{t("email", { email: session.user.email })}</p>
|
||||
<LogoutButton />
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
23
src/app/signin/page.tsx
Normal file
23
src/app/signin/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { signInAction } from "@/lib/actions/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
<form action={signInAction}>
|
||||
<input type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required />
|
||||
<input type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required />
|
||||
<LightButton type="submit">Sign In</LightButton>
|
||||
</form>
|
||||
<Link href={"/signup"}>Do not have an account? Sign up!</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/signup/page.tsx
Normal file
33
src/app/signup/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { signUpAction } from "@/lib/actions/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign Up</h1>
|
||||
<form action={signUpAction}>
|
||||
<input type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
required
|
||||
/>
|
||||
<input type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
/>
|
||||
<input type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<LightButton type="submit">Sign Up</LightButton>
|
||||
</form>
|
||||
|
||||
<Link href={"/signin"}>Already have an account? Sign in!</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/auth.ts
38
src/auth.ts
@@ -1,30 +1,20 @@
|
||||
import NextAuth from "next-auth";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
import { createUserIfNotExists, getUserIdByEmail } from "./lib/actions/services/userService";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import prisma from "./lib/db";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql"
|
||||
}),
|
||||
],
|
||||
|
||||
callbacks: {
|
||||
async signIn({ user }) {
|
||||
if (!user.email) return false;
|
||||
await createUserIfNotExists(user.email, user.name);
|
||||
return true
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
},
|
||||
async session({ session }) {
|
||||
if (session.user?.email) {
|
||||
const userId = await getUserIdByEmail(session.user.email);
|
||||
|
||||
if (userId) {
|
||||
session.user.id = userId.toString();
|
||||
}
|
||||
}
|
||||
return session;
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies()]
|
||||
});
|
||||
|
||||
46
src/components/LanguageSettings.tsx
Normal file
46
src/components/LanguageSettings.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import IMAGES from "@/config/images";
|
||||
import IconClick from "./IconClick";
|
||||
import { useState } from "react";
|
||||
import LightButton from "./buttons/LightButton";
|
||||
|
||||
export default function LanguageSettings() {
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
const handleLanguageClick = () => {
|
||||
setShowLanguageMenu((prev) => !prev);
|
||||
};
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<IconClick
|
||||
src={IMAGES.language_white}
|
||||
alt="language"
|
||||
disableOnHoverBgChange={true}
|
||||
onClick={handleLanguageClick}
|
||||
></IconClick>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
<div>
|
||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("en-US")}
|
||||
>
|
||||
English
|
||||
</LightButton>
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("zh-CN")}
|
||||
>
|
||||
中文
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IconClick from "./IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useState } from "react";
|
||||
import LightButton from "./buttons/LightButton";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Folder, Home, LoaderCircle } from "lucide-react";
|
||||
import { Folder, Home } from "lucide-react";
|
||||
import LanguageSettings from "./LanguageSettings";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function Navbar() {
|
||||
const t = await getTranslations("navbar");
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
export function Navbar() {
|
||||
const t = useTranslations("navbar");
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
const handleLanguageClick = () => {
|
||||
setShowLanguageMenu((prev) => !prev);
|
||||
};
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
};
|
||||
const session = useSession();
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
||||
<Link href={"/"} className="text-xl border-b hidden md:block">
|
||||
@@ -41,47 +33,21 @@ export function Navbar() {
|
||||
height={24}
|
||||
/>
|
||||
</Link>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
<div>
|
||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("en-US")}
|
||||
>
|
||||
English
|
||||
</LightButton>
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("zh-CN")}
|
||||
>
|
||||
中文
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<IconClick
|
||||
src={IMAGES.language_white}
|
||||
alt="language"
|
||||
disableOnHoverBgChange={true}
|
||||
onClick={handleLanguageClick}
|
||||
></IconClick>
|
||||
</div>
|
||||
<LanguageSettings />
|
||||
<Link href="/folders" className="md:block hidden">
|
||||
{t("folders")}
|
||||
</Link>
|
||||
<Link href="/folders" className="md:hidden block">
|
||||
<Folder />
|
||||
</Link>
|
||||
{session?.status === "authenticated" && (
|
||||
<div className="flex gap-2">
|
||||
{
|
||||
(() => {
|
||||
return session &&
|
||||
<Link href="/profile">{t("profile")}</Link>
|
||||
</div>
|
||||
)}
|
||||
{session?.status === "unauthenticated" && (
|
||||
<Link href="/login">{t("login")}</Link>
|
||||
)}
|
||||
{session?.status === "loading" && <LoaderCircle />}
|
||||
|| <Link href="/signin">{t("sign_in")}</Link>;
|
||||
|
||||
})()
|
||||
}
|
||||
<Link href="/changelog.txt">{t("about")}</Link>
|
||||
<Link
|
||||
className="hidden md:block"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function SessionWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
interface ContainerProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
43
src/lib/actions/auth.ts
Normal file
43
src/lib/actions/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function signUpAction(formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name
|
||||
}
|
||||
});
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function signInAction(formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
}
|
||||
});
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function signOutAction() {
|
||||
await auth.api.signOut({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
redirect("/login");
|
||||
}
|
||||
5
src/lib/auth-client.ts
Normal file
5
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.BETTER_AUTH_URL as string
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
TranslationHistoryArraySchema,
|
||||
TranslationHistorySchema,
|
||||
@@ -14,7 +16,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
try {
|
||||
const item = globalThis.localStorage.getItem(key);
|
||||
|
||||
if (!item) return [];
|
||||
if (!item) return [] as z.infer<T>;
|
||||
|
||||
const rawData = JSON.parse(item) as z.infer<T>;
|
||||
const result = schema.safeParse(rawData);
|
||||
@@ -26,11 +28,11 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
"Invalid data structure in localStorage:",
|
||||
result.error,
|
||||
);
|
||||
return [];
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${key} data:`, e);
|
||||
return [];
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
},
|
||||
set: (data: z.infer<T>) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
|
||||
import prisma from "../../db";
|
||||
|
||||
export async function getFoldersByUserId(userId: number) {
|
||||
export async function getFoldersByUserId(userId: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
@@ -23,7 +23,7 @@ export async function renameFolderById(id: number, newName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFoldersWithTotalPairsByUserId(userId: number) {
|
||||
export async function getFoldersWithTotalPairsByUserId(userId: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
127
src/lib/utils.ts
127
src/lib/utils.ts
@@ -20,3 +20,130 @@ export function shallowEqual<T extends object>(obj1: T, obj2: T): boolean {
|
||||
|
||||
return true;
|
||||
}
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
private readonly m: number = 0x80000000; // 2^31
|
||||
private readonly a: number = 1103515245;
|
||||
private readonly c: number = 12345;
|
||||
|
||||
constructor(seed?: number) {
|
||||
this.seed = seed || Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成0-1之间的随机数
|
||||
* @returns 0到1之间的随机浮点数
|
||||
*/
|
||||
next(): number {
|
||||
this.seed = (this.a * this.seed + this.c) % this.m;
|
||||
return this.seed / (this.m - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定范围的随机整数
|
||||
* @param min 最小值(包含)
|
||||
* @param max 最大值(包含)
|
||||
* @returns [min, max] 范围内的随机整数
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
if (min > max) {
|
||||
throw new Error('min must be less than or equal to max');
|
||||
}
|
||||
return Math.floor(this.next() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定范围的随机浮点数
|
||||
* @param min 最小值(包含)
|
||||
* @param max 最大值(不包含)
|
||||
* @returns [min, max) 范围内的随机浮点数
|
||||
*/
|
||||
nextFloat(min: number, max: number): number {
|
||||
if (min >= max) {
|
||||
throw new Error('min must be less than max');
|
||||
}
|
||||
return this.next() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定长度的随机数序列
|
||||
* @param length 序列长度
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @param type 生成类型:'integer' 或 'float'
|
||||
* @returns 随机数数组
|
||||
*/
|
||||
generateSequence(
|
||||
length: number,
|
||||
min: number = 0,
|
||||
max: number = 1,
|
||||
type: 'integer' | 'float' = 'integer'
|
||||
): number[] {
|
||||
const sequence: number[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (type === 'integer') {
|
||||
sequence.push(this.nextInt(min, max));
|
||||
} else {
|
||||
sequence.push(this.nextFloat(min, max));
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置种子
|
||||
* @param newSeed 新的种子值
|
||||
*/
|
||||
reset(newSeed?: number): void {
|
||||
this.seed = newSeed || Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前种子值
|
||||
* @returns 当前种子
|
||||
*/
|
||||
getSeed(): number {
|
||||
return this.seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机布尔值
|
||||
* @param probability 为 true 的概率,默认 0.5
|
||||
* @returns 随机布尔值
|
||||
*/
|
||||
nextBoolean(probability: number = 0.5): boolean {
|
||||
if (probability < 0 || probability > 1) {
|
||||
throw new Error('probability must be between 0 and 1');
|
||||
}
|
||||
return this.next() < probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数组中随机选择元素
|
||||
* @param array 源数组
|
||||
* @returns 随机选择的元素
|
||||
*/
|
||||
choice<T>(array: T[]): T {
|
||||
if (array.length === 0) {
|
||||
throw new Error('array cannot be empty');
|
||||
}
|
||||
const index = this.nextInt(0, array.length - 1);
|
||||
return array[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 打乱数组(Fisher-Yates 洗牌算法)
|
||||
* @param array 要打乱的数组
|
||||
* @returns 打乱后的新数组
|
||||
*/
|
||||
shuffle<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = this.nextInt(0, i);
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user