This commit is contained in:
10
.env.example
10
.env.example
@@ -1,8 +1,12 @@
|
||||
// LLM
|
||||
ZHIPU_API_KEY=
|
||||
AUTH_SECRET=
|
||||
ZHIPU_MODEL_NAME=
|
||||
|
||||
// Auth
|
||||
AUTH_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
NEXTAUTH_URL=
|
||||
DATABASE_URL=
|
||||
|
||||
// Database
|
||||
DATABASE_URL=
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -5,7 +5,7 @@ FROM node:23-alpine AS base
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
@@ -29,10 +29,17 @@ COPY . .
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# RUN \
|
||||
# if [ -f yarn.lock ]; then yarn run build; \
|
||||
# elif [ -f package-lock.json ]; then npm run build; \
|
||||
# elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
# else echo "Lockfile not found." && exit 1; \
|
||||
# fi
|
||||
|
||||
RUN pnpx prisma generate
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"next-intl": "^4.5.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"unstorage": "^1.17.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
unstorage:
|
||||
specifier: ^1.17.2
|
||||
version: 1.17.2
|
||||
@@ -4040,6 +4043,12 @@ packages:
|
||||
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -9954,6 +9963,11 @@ snapshots:
|
||||
slugify@1.6.6:
|
||||
optional: true
|
||||
|
||||
sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
||||
@@ -36,7 +36,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||
<Folder />
|
||||
<div className="flex-1 flex gap-2">
|
||||
<span className="group-hover:text-blue-500">
|
||||
{folder.name}
|
||||
{folder.id}. {folder.name}
|
||||
</span>
|
||||
<span>({folder.total_pairs})</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import {
|
||||
getFoldersByOwner,
|
||||
getFoldersWithTotalPairsByOwner,
|
||||
getOwnerByFolderId,
|
||||
} from "@/lib/services/folderService";
|
||||
|
||||
78
src/app/(features)/translator/AddToFolder.tsx
Normal file
78
src/app/(features)/translator/AddToFolder.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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 { getFoldersByOwner } from "@/lib/services/folderService";
|
||||
import { Folder } from "lucide-react";
|
||||
import { createTextPair } from "@/lib/services/textPairService";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AddToFolderProps {
|
||||
item: z.infer<typeof TranslationHistorySchema>;
|
||||
setShow: Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||
const session = useSession();
|
||||
const [folders, setFolders] = useState<folder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const username = session.data!.user!.name as string;
|
||||
getFoldersByOwner(username).then(setFolders);
|
||||
}, [session.data]);
|
||||
|
||||
if (session.status !== "authenticated") {
|
||||
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">
|
||||
<div>You are not authenticated</div>;
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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">
|
||||
<h1>Choose a Folder to Add to</h1>
|
||||
<div className="border border-gray-200 rounded-2xl">
|
||||
{(folders.length > 0 &&
|
||||
folders.map((folder) => (
|
||||
<button
|
||||
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"
|
||||
onClick={() => {
|
||||
createTextPair({
|
||||
text1: item.text1,
|
||||
text2: item.text2,
|
||||
locale1: item.locale1,
|
||||
locale2: item.locale2,
|
||||
folders: {
|
||||
connect: {
|
||||
id: folder.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Text pair added to folder");
|
||||
setShow(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to add text pair to folder");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Folder />
|
||||
{folder.id}. {folder.name}
|
||||
</button>
|
||||
))) || <div>No folders found</div>}
|
||||
</div>
|
||||
<LightButton onClick={() => setShow(false)}>Close</LightButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToFolder;
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Container from "@/components/cards/Container";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { VOICES } from "@/config/locales";
|
||||
@@ -9,10 +8,12 @@ import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/tts";
|
||||
import { letsFetch } from "@/lib/utils";
|
||||
import { letsFetch, shallowEqual } from "@/lib/utils";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import AddToFolder from "./AddToFolder";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
@@ -27,6 +28,10 @@ export default function TranslatorPage() {
|
||||
const [history, setHistory] = useState<
|
||||
z.infer<typeof TranslationHistorySchema>[]
|
||||
>(tlso.get());
|
||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||
typeof TranslationHistorySchema
|
||||
> | null>(null);
|
||||
|
||||
const lastTTS = useRef({
|
||||
text: "",
|
||||
@@ -67,8 +72,7 @@ export default function TranslatorPage() {
|
||||
|
||||
const checkUpdateLocalStorage = (item: typeof newItem) => {
|
||||
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
|
||||
tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
|
||||
setHistory(tlso.get());
|
||||
setHistory(tlsoPush(item as z.infer<typeof TranslationHistorySchema>));
|
||||
}
|
||||
};
|
||||
const innerStates = {
|
||||
@@ -187,11 +191,11 @@ export default function TranslatorPage() {
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard2 Component */}
|
||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||
<div className="h-8/12 w-full">{tresult}</div>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
||||
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||
{ipaTexts[1]}
|
||||
</div>
|
||||
<div className="h-2/12 w-full flex justify-end items-center">
|
||||
<div className="h-1/6 w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
src={IMAGES.copy_all}
|
||||
alt="copy"
|
||||
@@ -256,16 +260,47 @@ export default function TranslatorPage() {
|
||||
</button>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<Container className="m-6 flex flex-col p-6">
|
||||
<div className="m-6 flex flex-col items-center">
|
||||
<h1 className="text-2xl font-light">History</h1>
|
||||
<ul className="list-disc list-inside">
|
||||
<div className="border border-gray-200 rounded-2xl m-4">
|
||||
{history.map((item, index) => (
|
||||
<li key={index}>
|
||||
<span className="font-bold">{item.text1}</span> - {item.text2}
|
||||
</li>
|
||||
<div key={index}>
|
||||
<div className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<p className="text-sm font-light">{item.text1}</p>
|
||||
<p className="text-sm font-light">{item.text2}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddToFolder(true);
|
||||
setAddToFolderItem(item);
|
||||
}}
|
||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHistory(
|
||||
tlso.set(
|
||||
tlso.get().filter((v) => !shallowEqual(v, item)),
|
||||
) || [],
|
||||
);
|
||||
}}
|
||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
</div>
|
||||
{showAddToFolder && (
|
||||
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BOARD_WIDTH,
|
||||
TEXT_WIDTH,
|
||||
BOARD_HEIGHT,
|
||||
TEXT_SIZE,
|
||||
} from "@/config/word-board-config";
|
||||
import { Word } from "@/lib/interfaces";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export default function TheBoard({
|
||||
words,
|
||||
selectWord,
|
||||
}: {
|
||||
words: [
|
||||
{
|
||||
word: string;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
];
|
||||
setWords: Dispatch<SetStateAction<Word[]>>;
|
||||
selectWord: (word: string) => void;
|
||||
}) {
|
||||
function DraggableWord({ word }: { word: Word }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
|
||||
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
|
||||
fontSize: `${TEXT_SIZE}px`,
|
||||
}}
|
||||
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
|
||||
// onClick={inspect(word.word)}>{word.word}</span>))
|
||||
onClick={() => {
|
||||
selectWord(word.word);
|
||||
}}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${BOARD_WIDTH}px`,
|
||||
height: `${BOARD_HEIGHT}px`,
|
||||
}}
|
||||
className="relative rounded bg-white"
|
||||
>
|
||||
{words.map(
|
||||
(
|
||||
v: {
|
||||
word: string;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
i: number,
|
||||
) => {
|
||||
return <DraggableWord word={v} key={i}></DraggableWord>;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
import TheBoard from "@/app/(features)/word-board/TheBoard";
|
||||
import LightButton from "../../../components/buttons/LightButton";
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import { Word } from "@/lib/interfaces";
|
||||
import {
|
||||
BOARD_WIDTH,
|
||||
TEXT_WIDTH,
|
||||
BOARD_HEIGHT,
|
||||
TEXT_SIZE,
|
||||
} from "@/config/word-board-config";
|
||||
import { inspect } from "@/lib/utils";
|
||||
|
||||
export default function WordBoardPage() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||
const initialWords = [
|
||||
// 'apple',
|
||||
// 'banana',
|
||||
// 'cannon',
|
||||
// 'desktop',
|
||||
// 'kernel',
|
||||
// 'system',
|
||||
// 'programming',
|
||||
// 'owe'
|
||||
] as Array<string>;
|
||||
const [words, setWords] = useState(
|
||||
initialWords.map((v: string) => ({
|
||||
word: v,
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
})),
|
||||
);
|
||||
const generateNewWord = (word: string) => {
|
||||
const isOK = (w: Word) => {
|
||||
if (words.length === 0) return true;
|
||||
const tf = (ww: Word) =>
|
||||
({
|
||||
word: ww.word,
|
||||
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
|
||||
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
|
||||
}) as Word;
|
||||
const tfd_words = words.map(tf);
|
||||
const tfd_w = tf(w);
|
||||
for (const www of tfd_words) {
|
||||
const p1 = {
|
||||
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
|
||||
y: (www.y + www.y + TEXT_SIZE) / 2,
|
||||
};
|
||||
const p2 = {
|
||||
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
|
||||
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2,
|
||||
};
|
||||
if (
|
||||
Math.abs(p1.x - p2.x) <
|
||||
(TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
|
||||
Math.abs(p1.y - p2.y) < TEXT_SIZE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let new_word;
|
||||
let count = 0;
|
||||
do {
|
||||
new_word = {
|
||||
word: word,
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
};
|
||||
if (++count > 1000) return null;
|
||||
} while (!isOK(new_word));
|
||||
return new_word as Word;
|
||||
};
|
||||
const insertWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
const new_word = generateNewWord(word);
|
||||
if (!new_word) return;
|
||||
setWords([...words, new_word]);
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
const deleteWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
setWords(words.filter((v) => v.word !== word));
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
const importWords = () => {
|
||||
inputFileRef.current?.click();
|
||||
};
|
||||
const exportWords = () => {
|
||||
const blob = new Blob([JSON.stringify(words)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${Date.now()}.json`;
|
||||
a.style.display = "none";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const handleFileChange = () => {
|
||||
const files = inputFileRef.current?.files;
|
||||
if (files && files.length > 0) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result && typeof reader.result === "string")
|
||||
setWords(JSON.parse(reader.result) as [Word]);
|
||||
};
|
||||
reader.readAsText(files[0]);
|
||||
}
|
||||
};
|
||||
const deleteAll = () => {
|
||||
setWords([] as Array<Word>);
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
if (e.key === "Enter") {
|
||||
insertWord();
|
||||
}
|
||||
};
|
||||
const selectWord = (word: string) => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.value = word;
|
||||
};
|
||||
const searchWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
inspect(word)();
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
// const readWordAloud = () => {
|
||||
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
|
||||
// return;
|
||||
// if (!inputRef.current) return;
|
||||
// const word = inputRef.current.value.trim();
|
||||
// if (word === '') return;
|
||||
// inspect(word)();
|
||||
// inputRef.current.value = '';
|
||||
// }
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-screen h-screen justify-center items-center">
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
className="p-5 bg-gray-200 rounded shadow-2xl"
|
||||
>
|
||||
<TheBoard
|
||||
selectWord={selectWord}
|
||||
words={words as [Word]}
|
||||
setWords={setWords}
|
||||
/>
|
||||
<div className="flex justify-center rounded mt-3 gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder="word to operate"
|
||||
type="text"
|
||||
className="focus:outline-none border-b-2 border-black"
|
||||
/>
|
||||
<LightButton onClick={insertWord}>插入</LightButton>
|
||||
<LightButton onClick={deleteWord}>删除</LightButton>
|
||||
<LightButton onClick={searchWord}>搜索</LightButton>
|
||||
<LightButton onClick={importWords}>导入</LightButton>
|
||||
<LightButton onClick={exportWords}>导出</LightButton>
|
||||
<LightButton onClick={deleteAll}>删光</LightButton>
|
||||
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputFileRef}
|
||||
className="hidden"
|
||||
accept="application/json"
|
||||
onChange={handleFileChange}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { ChevronRight, Folder, FolderPlus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center } from "@/components/Center";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -13,11 +8,11 @@ import { folder } from "../../../generated/prisma/browser";
|
||||
import {
|
||||
createFolder,
|
||||
deleteFolderById,
|
||||
getFoldersByOwner,
|
||||
getFoldersWithTotalPairsByOwner,
|
||||
} from "@/lib/services/folderService";
|
||||
|
||||
interface FolderProps {
|
||||
folder: folder;
|
||||
folder: folder & { total_pairs: number };
|
||||
deleteCallback: () => void;
|
||||
openCallback: () => void;
|
||||
}
|
||||
@@ -34,14 +29,16 @@ const FolderCard = ({ folder, deleteCallback, openCallback }: FolderProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{folder.id}. {folder.name} ({folder.total_pairs})
|
||||
</h3>
|
||||
{/*<p className="text-sm text-gray-500">{} items</p>*/}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400">#{folder.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -58,12 +55,14 @@ const FolderCard = ({ folder, deleteCallback, openCallback }: FolderProps) => {
|
||||
};
|
||||
|
||||
export default function FoldersClient({ username }: { username: string }) {
|
||||
const [folders, setFolders] = useState<folder[]>([]);
|
||||
const [folders, setFolders] = useState<(folder & { total_pairs: number })[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
getFoldersByOwner(username).then((folders) => {
|
||||
getFoldersWithTotalPairsByOwner(username).then((folders) => {
|
||||
setFolders(folders);
|
||||
});
|
||||
}, [username]);
|
||||
@@ -71,7 +70,7 @@ export default function FoldersClient({ username }: { username: string }) {
|
||||
const updateFolders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updatedFolders = await getFoldersByOwner(username);
|
||||
const updatedFolders = await getFoldersWithTotalPairsByOwner(username);
|
||||
setFolders(updatedFolders);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Edit, Plus, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Center } from "@/components/Center";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
@@ -9,12 +9,9 @@ import {
|
||||
createTextPair,
|
||||
deleteTextPairById,
|
||||
getTextPairsByFolderId,
|
||||
updateTextPairById,
|
||||
} from "@/lib/services/textPairService";
|
||||
import AddTextPairModal from "./AddTextPairModal";
|
||||
import TextPairCard from "./TextPairCard";
|
||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||
import { text_pairUpdateInput } from "../../../../generated/prisma/models";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
|
||||
export interface TextPair {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import SessionWrapper from "@/lib/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -27,6 +28,7 @@ export default async function RootLayout({
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import DarkButton from "@/components/buttons/DarkButton";
|
||||
import { useEffect } from "react";
|
||||
import ACard from "@/components/cards/ACard";
|
||||
import { Center } from "@/components/Center";
|
||||
import Container from "@/components/cards/Container";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
|
||||
export default function MePage() {
|
||||
@@ -22,7 +21,7 @@ export default function MePage() {
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<ACard>
|
||||
<Container className="p-6">
|
||||
<h1>My Profile</h1>
|
||||
{(session.data?.user?.image as string) && (
|
||||
<Image
|
||||
@@ -35,9 +34,8 @@ export default function MePage() {
|
||||
)}
|
||||
<p>{session.data?.user?.name}</p>
|
||||
<p>Email: {session.data?.user?.email}</p>
|
||||
<DarkButton onClick={signOut}>Logout</DarkButton>
|
||||
|
||||
</ACard>
|
||||
<LightButton onClick={signOut}>Logout</LightButton>
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import IMAGES from "@/config/images";
|
||||
import { useState } from "react";
|
||||
import LightButton from "./buttons/LightButton";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Folder, Home } from "lucide-react";
|
||||
import { Folder, Home, LoaderCircle } from "lucide-react";
|
||||
|
||||
export function Navbar() {
|
||||
const t = useTranslations("navbar");
|
||||
@@ -73,13 +73,15 @@ export function Navbar() {
|
||||
<Link href="/folders" className="md:hidden block">
|
||||
<Folder />
|
||||
</Link>
|
||||
{session?.status === "authenticated" ? (
|
||||
{session?.status === "authenticated" && (
|
||||
<div className="flex gap-2">
|
||||
<Link href="/profile">{t("profile")}</Link>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{session?.status === "unauthenticated" && (
|
||||
<Link href="/login">{t("login")}</Link>
|
||||
)}
|
||||
{session?.status === "loading" && <LoaderCircle />}
|
||||
<Link href="/changelog.txt">{t("about")}</Link>
|
||||
<Link
|
||||
className="hidden md:block"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { format } from "util";
|
||||
|
||||
async function callZhipuAPI(
|
||||
messages: { role: string; content: string }[],
|
||||
model = "glm-4.6",
|
||||
model = process.env.ZHIPU_MODEL_NAME,
|
||||
) {
|
||||
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
|
||||
|
||||
@@ -54,10 +54,7 @@ export async function simpleGetLLMAnswer(
|
||||
return Response.json({
|
||||
status: "success",
|
||||
message: await getLLMAnswer(
|
||||
format(
|
||||
prompt,
|
||||
...args.map((v) => searchParams.get(v)),
|
||||
),
|
||||
format(prompt, ...args.map((v) => searchParams.get(v))),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
TranslationHistoryArraySchema,
|
||||
TranslationHistorySchema,
|
||||
} from "@/lib/interfaces";
|
||||
import { getLocalStorageOperator } from "@/lib/utils";
|
||||
import { getLocalStorageOperator, shallowEqual } from "@/lib/utils";
|
||||
import z from "zod";
|
||||
|
||||
const MAX_HISTORY_LENGTH = 50;
|
||||
@@ -11,9 +11,11 @@ export const tlso = getLocalStorageOperator<
|
||||
typeof TranslationHistoryArraySchema
|
||||
>("translator", TranslationHistoryArraySchema);
|
||||
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
||||
tlso.set(
|
||||
[...tlso.get(), item as z.infer<typeof TranslationHistorySchema>].slice(
|
||||
-MAX_HISTORY_LENGTH,
|
||||
),
|
||||
);
|
||||
const oldHistory = tlso.get();
|
||||
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
|
||||
|
||||
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
|
||||
tlso.set(newHistory);
|
||||
|
||||
return newHistory;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { folder } from "../../../generated/prisma/client";
|
||||
import {
|
||||
folderCreateInput,
|
||||
folderUpdateInput,
|
||||
|
||||
@@ -92,6 +92,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
set: (data: z.infer<T>) => {
|
||||
if (!localStorage) return;
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
return data;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -128,3 +129,22 @@ export const letsFetch = (
|
||||
export function isNonNegativeInteger(str: string): boolean {
|
||||
return /^\d+$/.test(str);
|
||||
}
|
||||
|
||||
export function shallowEqual<T extends object>(obj1: T, obj2: T): boolean {
|
||||
const keys1 = Object.keys(obj1) as Array<keyof T>;
|
||||
const keys2 = Object.keys(obj2) as Array<keyof T>;
|
||||
|
||||
// 首先检查键的数量是否相同
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 然后逐个比较键值对
|
||||
for (const key of keys1) {
|
||||
if (obj1[key] !== obj2[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user