...
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-11-16 12:04:09 +08:00
parent 72c6791d93
commit 0e3d41829c
19 changed files with 215 additions and 312 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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
View File

@@ -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:

View File

@@ -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>

View File

@@ -3,7 +3,6 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
getFoldersByOwner,
getFoldersWithTotalPairsByOwner,
getOwnerByFolderId,
} from "@/lib/services/folderService";

View 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;

View File

@@ -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>
)}
</>
);

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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))),
),
});
}

View File

@@ -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;
};

View File

@@ -1,6 +1,5 @@
"use server";
import { folder } from "../../../generated/prisma/client";
import {
folderCreateInput,
folderUpdateInput,

View File

@@ -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;
}