This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
|
// LLM
|
||||||
ZHIPU_API_KEY=
|
ZHIPU_API_KEY=
|
||||||
AUTH_SECRET=
|
ZHIPU_MODEL_NAME=
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
AUTH_SECRET=
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
NEXTAUTH_URL=
|
NEXTAUTH_URL=
|
||||||
|
|
||||||
|
// Database
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
15
Dockerfile
15
Dockerfile
@@ -5,7 +5,7 @@ FROM node:23-alpine AS base
|
|||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# 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.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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 \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn run build; \
|
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm 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; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"next-intl": "^4.5.2",
|
"next-intl": "^4.5.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"unstorage": "^1.17.2",
|
"unstorage": "^1.17.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0(react@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:
|
unstorage:
|
||||||
specifier: ^1.17.2
|
specifier: ^1.17.2
|
||||||
version: 1.17.2
|
version: 1.17.2
|
||||||
@@ -4040,6 +4043,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
|
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
|
||||||
engines: {node: '>=8.0.0'}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -9954,6 +9963,11 @@ snapshots:
|
|||||||
slugify@1.6.6:
|
slugify@1.6.6:
|
||||||
optional: true
|
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-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
<Folder />
|
<Folder />
|
||||||
<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">
|
||||||
{folder.name}
|
{folder.id}. {folder.name}
|
||||||
</span>
|
</span>
|
||||||
<span>({folder.total_pairs})</span>
|
<span>({folder.total_pairs})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import {
|
import {
|
||||||
getFoldersByOwner,
|
|
||||||
getFoldersWithTotalPairsByOwner,
|
getFoldersWithTotalPairsByOwner,
|
||||||
getOwnerByFolderId,
|
getOwnerByFolderId,
|
||||||
} from "@/lib/services/folderService";
|
} 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";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
import Container from "@/components/cards/Container";
|
|
||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { VOICES } from "@/config/locales";
|
import { VOICES } from "@/config/locales";
|
||||||
@@ -9,10 +8,12 @@ import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
|||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
|
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/tts";
|
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 { useTranslations } from "next-intl";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import AddToFolder from "./AddToFolder";
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
@@ -27,6 +28,10 @@ export default function TranslatorPage() {
|
|||||||
const [history, setHistory] = useState<
|
const [history, setHistory] = useState<
|
||||||
z.infer<typeof TranslationHistorySchema>[]
|
z.infer<typeof TranslationHistorySchema>[]
|
||||||
>(tlso.get());
|
>(tlso.get());
|
||||||
|
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||||
|
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||||
|
typeof TranslationHistorySchema
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
const lastTTS = useRef({
|
const lastTTS = useRef({
|
||||||
text: "",
|
text: "",
|
||||||
@@ -67,8 +72,7 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
const checkUpdateLocalStorage = (item: typeof newItem) => {
|
const checkUpdateLocalStorage = (item: typeof newItem) => {
|
||||||
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
|
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
|
||||||
tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
|
setHistory(tlsoPush(item as z.infer<typeof TranslationHistorySchema>));
|
||||||
setHistory(tlso.get());
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const innerStates = {
|
const innerStates = {
|
||||||
@@ -187,11 +191,11 @@ export default function TranslatorPage() {
|
|||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard2 Component */}
|
{/* ICard2 Component */}
|
||||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
<div className="h-8/12 w-full">{tresult}</div>
|
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
{ipaTexts[1]}
|
{ipaTexts[1]}
|
||||||
</div>
|
</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
|
<IconClick
|
||||||
src={IMAGES.copy_all}
|
src={IMAGES.copy_all}
|
||||||
alt="copy"
|
alt="copy"
|
||||||
@@ -256,16 +260,47 @@ export default function TranslatorPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{history.length > 0 && (
|
{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>
|
<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) => (
|
{history.map((item, index) => (
|
||||||
<li key={index}>
|
<div key={index}>
|
||||||
<span className="font-bold">{item.text1}</span> - {item.text2}
|
<div className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start">
|
||||||
</li>
|
<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>
|
</div>
|
||||||
</Container>
|
{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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { ChevronRight, Folder, FolderPlus, Trash2 } from "lucide-react";
|
||||||
ChevronRight,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/Center";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -13,11 +8,11 @@ import { folder } from "../../../generated/prisma/browser";
|
|||||||
import {
|
import {
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFolderById,
|
deleteFolderById,
|
||||||
getFoldersByOwner,
|
getFoldersWithTotalPairsByOwner,
|
||||||
} from "@/lib/services/folderService";
|
} from "@/lib/services/folderService";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderProps {
|
||||||
folder: folder;
|
folder: folder & { total_pairs: number };
|
||||||
deleteCallback: () => void;
|
deleteCallback: () => void;
|
||||||
openCallback: () => void;
|
openCallback: () => void;
|
||||||
}
|
}
|
||||||
@@ -34,14 +29,16 @@ const FolderCard = ({ folder, deleteCallback, openCallback }: FolderProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<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>*/}
|
{/*<p className="text-sm text-gray-500">{} items</p>*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-400">#{folder.id}</div>
|
<div className="text-xs text-gray-400">#{folder.id}</div>
|
||||||
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -58,12 +55,14 @@ const FolderCard = ({ folder, deleteCallback, openCallback }: FolderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FoldersClient({ username }: { username: string }) {
|
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 [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getFoldersByOwner(username).then((folders) => {
|
getFoldersWithTotalPairsByOwner(username).then((folders) => {
|
||||||
setFolders(folders);
|
setFolders(folders);
|
||||||
});
|
});
|
||||||
}, [username]);
|
}, [username]);
|
||||||
@@ -71,7 +70,7 @@ export default function FoldersClient({ username }: { username: string }) {
|
|||||||
const updateFolders = async () => {
|
const updateFolders = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const updatedFolders = await getFoldersByOwner(username);
|
const updatedFolders = await getFoldersWithTotalPairsByOwner(username);
|
||||||
setFolders(updatedFolders);
|
setFolders(updatedFolders);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Edit, Plus, Trash2, X } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/Center";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
@@ -9,12 +9,9 @@ import {
|
|||||||
createTextPair,
|
createTextPair,
|
||||||
deleteTextPairById,
|
deleteTextPairById,
|
||||||
getTextPairsByFolderId,
|
getTextPairsByFolderId,
|
||||||
updateTextPairById,
|
|
||||||
} from "@/lib/services/textPairService";
|
} from "@/lib/services/textPairService";
|
||||||
import AddTextPairModal from "./AddTextPairModal";
|
import AddTextPairModal from "./AddTextPairModal";
|
||||||
import TextPairCard from "./TextPairCard";
|
import TextPairCard from "./TextPairCard";
|
||||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
|
||||||
import { text_pairUpdateInput } from "../../../../generated/prisma/models";
|
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
|
|
||||||
export interface TextPair {
|
export interface TextPair {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Viewport } from "next";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import SessionWrapper from "@/lib/SessionWrapper";
|
import SessionWrapper from "@/lib/SessionWrapper";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@@ -27,6 +28,7 @@ export default async function RootLayout({
|
|||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<Navbar></Navbar>
|
<Navbar></Navbar>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DarkButton from "@/components/buttons/DarkButton";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import ACard from "@/components/cards/ACard";
|
|
||||||
import { Center } from "@/components/Center";
|
import { Center } from "@/components/Center";
|
||||||
|
import Container from "@/components/cards/Container";
|
||||||
import LightButton from "@/components/buttons/LightButton";
|
import LightButton from "@/components/buttons/LightButton";
|
||||||
|
|
||||||
export default function MePage() {
|
export default function MePage() {
|
||||||
@@ -22,7 +21,7 @@ export default function MePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
<ACard>
|
<Container className="p-6">
|
||||||
<h1>My Profile</h1>
|
<h1>My Profile</h1>
|
||||||
{(session.data?.user?.image as string) && (
|
{(session.data?.user?.image as string) && (
|
||||||
<Image
|
<Image
|
||||||
@@ -35,9 +34,8 @@ export default function MePage() {
|
|||||||
)}
|
)}
|
||||||
<p>{session.data?.user?.name}</p>
|
<p>{session.data?.user?.name}</p>
|
||||||
<p>Email: {session.data?.user?.email}</p>
|
<p>Email: {session.data?.user?.email}</p>
|
||||||
<DarkButton onClick={signOut}>Logout</DarkButton>
|
<LightButton onClick={signOut}>Logout</LightButton>
|
||||||
|
</Container>
|
||||||
</ACard>
|
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import IMAGES from "@/config/images";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import LightButton from "./buttons/LightButton";
|
import LightButton from "./buttons/LightButton";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Folder, Home } from "lucide-react";
|
import { Folder, Home, LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const t = useTranslations("navbar");
|
const t = useTranslations("navbar");
|
||||||
@@ -73,13 +73,15 @@ export function Navbar() {
|
|||||||
<Link href="/folders" className="md:hidden block">
|
<Link href="/folders" className="md:hidden block">
|
||||||
<Folder />
|
<Folder />
|
||||||
</Link>
|
</Link>
|
||||||
{session?.status === "authenticated" ? (
|
{session?.status === "authenticated" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href="/profile">{t("profile")}</Link>
|
<Link href="/profile">{t("profile")}</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
{session?.status === "unauthenticated" && (
|
||||||
<Link href="/login">{t("login")}</Link>
|
<Link href="/login">{t("login")}</Link>
|
||||||
)}
|
)}
|
||||||
|
{session?.status === "loading" && <LoaderCircle />}
|
||||||
<Link href="/changelog.txt">{t("about")}</Link>
|
<Link href="/changelog.txt">{t("about")}</Link>
|
||||||
<Link
|
<Link
|
||||||
className="hidden md:block"
|
className="hidden md:block"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { format } from "util";
|
|||||||
|
|
||||||
async function callZhipuAPI(
|
async function callZhipuAPI(
|
||||||
messages: { role: string; content: string }[],
|
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";
|
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
|
||||||
|
|
||||||
@@ -54,10 +54,7 @@ export async function simpleGetLLMAnswer(
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
status: "success",
|
status: "success",
|
||||||
message: await getLLMAnswer(
|
message: await getLLMAnswer(
|
||||||
format(
|
format(prompt, ...args.map((v) => searchParams.get(v))),
|
||||||
prompt,
|
|
||||||
...args.map((v) => searchParams.get(v)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
TranslationHistoryArraySchema,
|
TranslationHistoryArraySchema,
|
||||||
TranslationHistorySchema,
|
TranslationHistorySchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { getLocalStorageOperator } from "@/lib/utils";
|
import { getLocalStorageOperator, shallowEqual } from "@/lib/utils";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const MAX_HISTORY_LENGTH = 50;
|
const MAX_HISTORY_LENGTH = 50;
|
||||||
@@ -11,9 +11,11 @@ export const tlso = getLocalStorageOperator<
|
|||||||
typeof TranslationHistoryArraySchema
|
typeof TranslationHistoryArraySchema
|
||||||
>("translator", TranslationHistoryArraySchema);
|
>("translator", TranslationHistoryArraySchema);
|
||||||
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
||||||
tlso.set(
|
const oldHistory = tlso.get();
|
||||||
[...tlso.get(), item as z.infer<typeof TranslationHistorySchema>].slice(
|
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
|
||||||
-MAX_HISTORY_LENGTH,
|
|
||||||
),
|
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
|
||||||
);
|
tlso.set(newHistory);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { folder } from "../../../generated/prisma/client";
|
|
||||||
import {
|
import {
|
||||||
folderCreateInput,
|
folderCreateInput,
|
||||||
folderUpdateInput,
|
folderUpdateInput,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
|||||||
set: (data: z.infer<T>) => {
|
set: (data: z.infer<T>) => {
|
||||||
if (!localStorage) return;
|
if (!localStorage) return;
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
localStorage.setItem(key, JSON.stringify(data));
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -128,3 +129,22 @@ export const letsFetch = (
|
|||||||
export function isNonNegativeInteger(str: string): boolean {
|
export function isNonNegativeInteger(str: string): boolean {
|
||||||
return /^\d+$/.test(str);
|
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