From 465c01be44e3c0d087e08fa43cdb5d9cc3478ff5 Mon Sep 17 00:00:00 2001 From: nzambello Date: Mon, 7 Aug 2023 13:02:34 +0200 Subject: [PATCH] feat: add account mng with key, add translations features --- app/routes/_index.tsx | 103 +++------------------------- app/routes/account.delete.tsx | 125 ++++++++++++++++++++++++++++++++++ app/routes/account.tsx | 125 ++++++++++++++++++++++++++++++++++ app/routes/join.tsx | 2 +- app/routes/login.tsx | 4 +- app/routes/t.new.tsx | 117 ++++++++++++++++++++++++++++--- app/routes/t.tsx | 84 ++++++++++++++++++----- package.json | 1 + yarn.lock | 22 +++++- 9 files changed, 457 insertions(+), 126 deletions(-) create mode 100644 app/routes/account.delete.tsx diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 83e1f6b..41ece3c 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -3,40 +3,30 @@ import { Link } from "@remix-run/react"; import { useOptionalUser } from "~/utils"; -export const meta: V2_MetaFunction = () => [{ title: "Remix Notes" }]; +export const meta: V2_MetaFunction = () => [{ title: "TranslAIte" }]; export default function Index() { const user = useOptionalUser(); return (
-
+
-
- Sonic Youth On Stage -
-

- - Indie Stack + Transl + + AI + te

-

- Check the README.md file for instructions on how to get this - project deployed. -

{user ? ( - View Notes for {user.email} + Translate now ) : (
@@ -55,86 +45,9 @@ export default function Index() {
)}
- - Remix -
- -
-
- {[ - { - src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg", - alt: "Fly.io", - href: "https://fly.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg", - alt: "SQLite", - href: "https://sqlite.org", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", - alt: "Prisma", - href: "https://prisma.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", - alt: "Tailwind", - href: "https://tailwindcss.com", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", - alt: "Cypress", - href: "https://www.cypress.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", - alt: "MSW", - href: "https://mswjs.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", - alt: "Vitest", - href: "https://vitest.dev", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", - alt: "Testing Library", - href: "https://testing-library.com", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", - alt: "Prettier", - href: "https://prettier.io", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", - alt: "ESLint", - href: "https://eslint.org", - }, - { - src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", - alt: "TypeScript", - href: "https://typescriptlang.org", - }, - ].map((img) => ( - - {img.alt} - - ))} -
-
); diff --git a/app/routes/account.delete.tsx b/app/routes/account.delete.tsx new file mode 100644 index 0000000..3b577d6 --- /dev/null +++ b/app/routes/account.delete.tsx @@ -0,0 +1,125 @@ +import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useLoaderData } from "@remix-run/react"; +import { useRef, useEffect } from "react"; + +import { + deleteUserByEmail, + updateUser, + verifyLogin, +} from "~/models/user.server"; +import { logout, requireUser } from "~/session.server"; + +export const action = async ({ request }: ActionArgs) => { + const user = await requireUser(request); + const formData = await request.formData(); + const password = formData.get("password"); + + if (request.method !== "DELETE") { + return json({ errors: { password: null } }, { status: 422 }); + } + + if (typeof password !== "string" || password.length === 0) { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { password: "Password is too short" } }, + { status: 400 } + ); + } + + const checkedUser = await verifyLogin(user.email, password); + + if (!checkedUser) { + return json( + { errors: { password: "Password is not correct" } }, + { status: 400 } + ); + } + + await deleteUserByEmail(user.email); + return logout(request); +}; + +export const meta: V2_MetaFunction = () => [{ title: "Account | TranslAIte" }]; + +export default function Account() { + const actionData = useActionData(); + + const passwordRef = useRef(null); + + useEffect(() => { + if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( + { + window.history.back(); + }} + className="position-fixed left-1/2 top-1/2 m-auto w-full max-w-md -translate-x-1/2 -translate-y-1/2 transform space-y-6 rounded-lg bg-white px-8 py-8 shadow-lg" + > +
+

+ Are you sure you want to delete your account? This action cannot be + undone. +

+

Type your password to confirm

+ +
+ +
+ + {actionData?.errors?.password ? ( +
+ {actionData.errors.password} +
+ ) : null} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/app/routes/account.tsx b/app/routes/account.tsx index e69de29..bd61849 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -0,0 +1,125 @@ +import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { + Form, + Link, + Outlet, + useActionData, + useLoaderData, +} from "@remix-run/react"; + +import { updateUser } from "~/models/user.server"; +import { requireUser } from "~/session.server"; + +export const loader = async ({ request }: LoaderArgs) => { + const user = await requireUser(request); + return json({ user }); +}; + +export const action = async ({ request }: ActionArgs) => { + const user = await requireUser(request); + const formData = await request.formData(); + const openAIKey = formData.get("openAIKey"); + + if (typeof openAIKey !== "string" || openAIKey.length === 0) { + return json( + { user: null, errors: { openAIKey: "Open AI Key is required" } }, + { status: 400 } + ); + } + + const updatedUser = await updateUser(user.email, { openAIKey }); + + return json( + { user: updatedUser, errors: { openAIKey: null } }, + { status: 200 } + ); +}; + +export const meta: V2_MetaFunction = () => [{ title: "Account | TranslAIte" }]; + +export default function Account() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + + return ( +
+
+

+ TranslAIte +

+
+
+ +
+
+
+ +
+
+
+ +
+ + {actionData?.errors?.openAIKey ? ( +
+ {actionData.errors.openAIKey} +
+ ) : null} +
+
+ + +
+ + {actionData?.user?.openAIKey ? ( +
+

Account updated!

+
+ ) : null} + +
+ +
+ + Delete account + +
+
+ +
+ ); +} diff --git a/app/routes/join.tsx b/app/routes/join.tsx index ccedff5..a9b45ba 100644 --- a/app/routes/join.tsx +++ b/app/routes/join.tsx @@ -63,7 +63,7 @@ export const action = async ({ request }: ActionArgs) => { }); }; -export const meta: V2_MetaFunction = () => [{ title: "Sign Up" }]; +export const meta: V2_MetaFunction = () => [{ title: "Sign Up | TranslAIte" }]; export default function Join() { const [searchParams] = useSearchParams(); diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 25a8f92..a650b79 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -58,11 +58,11 @@ export const action = async ({ request }: ActionArgs) => { }); }; -export const meta: V2_MetaFunction = () => [{ title: "Login" }]; +export const meta: V2_MetaFunction = () => [{ title: "Login | TranslAIte" }]; export default function LoginPage() { const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || "/notes"; + const redirectTo = searchParams.get("redirectTo") || "/t"; const actionData = useActionData(); const emailRef = useRef(null); const passwordRef = useRef(null); diff --git a/app/routes/t.new.tsx b/app/routes/t.new.tsx index 2925391..d26c331 100644 --- a/app/routes/t.new.tsx +++ b/app/routes/t.new.tsx @@ -1,13 +1,15 @@ -import type { ActionArgs } from "@remix-run/node"; +import type { ActionArgs, LoaderArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; -import { Form, useActionData } from "@remix-run/react"; +import { Form, Link, useActionData, useLoaderData } from "@remix-run/react"; import { useEffect, useRef } from "react"; import { createTranslation } from "~/models/translation.server"; -import { requireUserId } from "~/session.server"; +import { requireUser } from "~/session.server"; + +import { Configuration, OpenAIApi } from "openai"; export const action = async ({ request }: ActionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const formData = await request.formData(); const lang = formData.get("lang"); @@ -15,27 +17,103 @@ export const action = async ({ request }: ActionArgs) => { if (typeof lang !== "string" || lang.length === 0) { return json( - { errors: { text: null, lang: "Lang is required" } }, + { errors: { text: null, lang: "Lang is required", result: null } }, { status: 400 } ); } if (typeof text !== "string" || text.length === 0) { return json( - { errors: { text: "Text is required", lang: null } }, + { errors: { text: "Text is required", lang: null, result: null } }, { status: 400 } ); } - const result = "test"; + if (!user.openAIKey?.length) { + return redirect("/account"); + } - const t = await createTranslation({ lang, text, result, userId }); + try { + const configuration = new Configuration({ + apiKey: user.openAIKey, + }); + const openai = new OpenAIApi(configuration); - return redirect(`/t/${t.id}`); + const completion = await openai.createChatCompletion({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: `You will be provided with a sentence, your task is to translate it into ${lang}.`, + }, + { role: "user", content: text }, + ], + }); + const result = completion.data.choices[0].message?.content; + + if (typeof result !== "string" || result.length === 0) { + return json( + { + errors: { + text: null, + lang: null, + result: "Error while retrieving translation result", + }, + }, + { status: 500 } + ); + } + + const t = await createTranslation({ lang, text, result, userId: user.id }); + + return redirect(`/t/${t.id}`); + } catch (e) { + let error = e as any; + if (error.response) { + console.error(error.response.status); + console.error(error.response.data); + + return json( + { + errors: { + text: null, + lang: null, + result: `[${error.response.status}] ${ + error.response.data || error.message + }`, + }, + }, + { status: 500 } + ); + } else { + console.error(error.message); + return json( + { + errors: { + text: null, + lang: null, + result: `[${error.name}] ${error.message}`, + }, + }, + { status: 500 } + ); + } + } +}; + +export const loader = async ({ params, request }: LoaderArgs) => { + const user = await requireUser(request); + + if (!user.openAIKey?.length) { + return redirect("/account"); + } + + return json({ userHasOpenAIKey: true }); }; export default function NewTranslationPage() { const actionData = useActionData(); + const loaderData = useLoaderData(); const langRef = useRef(null); const textRef = useRef(null); @@ -63,6 +141,7 @@ export default function NewTranslationPage() { + + {loaderData?.userHasOpenAIKey === false && ( +
+

+ You need to add your OpenAI API key to your account before you can + translate text. +

+

+ Go to your account to add your key. +

+
+ )} + + {actionData?.errors?.result && ( +
+

{actionData.errors.result}

+
+ )} ); } diff --git a/app/routes/t.tsx b/app/routes/t.tsx index 292c2ed..d0dec1f 100644 --- a/app/routes/t.tsx +++ b/app/routes/t.tsx @@ -1,10 +1,10 @@ import type { LoaderArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; +import { useEffect, useState } from "react"; import { getTranslationsListItems } from "~/models/translation.server"; import { requireUserId } from "~/session.server"; -import { useUser } from "~/utils"; export const loader = async ({ request }: LoaderArgs) => { const userId = await requireUserId(request); @@ -12,15 +12,32 @@ export const loader = async ({ request }: LoaderArgs) => { return json({ translations }); }; -export default function NotesPage() { +export default function TranslationsPage() { const data = useLoaderData(); - const user = useUser(); + const [expanded, _setExpanded] = useState(false); + const setExpanded: typeof _setExpanded = (value) => { + const isMobile = window.matchMedia("(max-width: 640px)").matches; + + if (isMobile) { + _setExpanded(value); + } else { + _setExpanded(true); + } + }; + + useEffect(() => { + const isMobile = window.matchMedia("(max-width: 640px)").matches; + + if (!isMobile) { + _setExpanded(true); + } + }, []); return (
-

- Translations +

+ TranslAIte

-
- - + New Translation +
+ )) + )} + -
+
+ +
+ + +
diff --git a/package.json b/package.json index 7df46e8..a2a4d1c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@remix-run/serve": "^1.19.2", "bcryptjs": "^2.4.3", "isbot": "^3.6.12", + "openai": "^3.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "tiny-invariant": "^1.3.1" diff --git a/yarn.lock b/yarn.lock index 91d697e..9efd1ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3774,6 +3774,15 @@ __metadata: languageName: node linkType: hard +"axios@npm:^0.26.0": + version: 0.26.1 + resolution: "axios@npm:0.26.1" + dependencies: + follow-redirects: ^1.14.8 + checksum: d9eb58ff4bc0b36a04783fc9ff760e9245c829a5a1052ee7ca6013410d427036b1d10d04e7380c02f3508c5eaf3485b1ae67bd2adbfec3683704745c8d7a6e1a + languageName: node + linkType: hard + "axios@npm:^0.27.2": version: 0.27.2 resolution: "axios@npm:0.27.2" @@ -6381,7 +6390,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.9": +"follow-redirects@npm:^1.14.8, follow-redirects@npm:^1.14.9": version: 1.15.2 resolution: "follow-redirects@npm:1.15.2" peerDependenciesMeta: @@ -9675,6 +9684,16 @@ __metadata: languageName: node linkType: hard +"openai@npm:^3.3.0": + version: 3.3.0 + resolution: "openai@npm:3.3.0" + dependencies: + axios: ^0.26.0 + form-data: ^4.0.0 + checksum: 28ccff8c09b6f47828c9583bb3bafc38a8459c76ea10eb9e08ca880f65523c5a9cc6c5f3c7669dded6f4c93e7cf49dd5c4dbfd12732a0f958c923117740d677b + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -12094,6 +12113,7 @@ __metadata: isbot: ^3.6.12 msw: ^1.2.2 npm-run-all: ^4.1.5 + openai: ^3.3.0 postcss: ^8.4.24 prettier: 2.8.8 prettier-plugin-tailwindcss: ^0.3.0