feat: add account mng with key, add translations features
This commit is contained in:
parent
83300a4701
commit
465c01be44
|
|
@ -3,40 +3,30 @@ import { Link } from "@remix-run/react";
|
||||||
|
|
||||||
import { useOptionalUser } from "~/utils";
|
import { useOptionalUser } from "~/utils";
|
||||||
|
|
||||||
export const meta: V2_MetaFunction = () => [{ title: "Remix Notes" }];
|
export const meta: V2_MetaFunction = () => [{ title: "TranslAIte" }];
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const user = useOptionalUser();
|
const user = useOptionalUser();
|
||||||
return (
|
return (
|
||||||
<main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
|
<main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
|
||||||
<div className="relative sm:pb-16 sm:pt-8">
|
<div className="relative sm:pb-16 sm:pt-8">
|
||||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div className="mx-auto min-w-[80vw] max-w-7xl sm:px-6 lg:px-8">
|
||||||
<div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
|
<div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
|
||||||
<div className="absolute inset-0">
|
|
||||||
<img
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
src="https://user-images.githubusercontent.com/1500684/157774694-99820c51-8165-4908-a031-34fc371ac0d6.jpg"
|
|
||||||
alt="Sonic Youth On Stage"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-[color:rgba(254,204,27,0.5)] mix-blend-multiply" />
|
|
||||||
</div>
|
|
||||||
<div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
|
<div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
|
||||||
<h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
|
<h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
|
||||||
<span className="block uppercase text-yellow-500 drop-shadow-md">
|
Transl
|
||||||
Indie Stack
|
<span className="uppercase text-yellow-500 drop-shadow-md">
|
||||||
|
AI
|
||||||
</span>
|
</span>
|
||||||
|
te
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl">
|
|
||||||
Check the README.md file for instructions on how to get this
|
|
||||||
project deployed.
|
|
||||||
</p>
|
|
||||||
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
|
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link
|
<Link
|
||||||
to="/notes"
|
to="/t/new"
|
||||||
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
||||||
>
|
>
|
||||||
View Notes for {user.email}
|
Translate now
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
|
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
|
||||||
|
|
@ -55,86 +45,9 @@ export default function Index() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a href="https://remix.run">
|
|
||||||
<img
|
|
||||||
src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg"
|
|
||||||
alt="Remix"
|
|
||||||
className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
|
||||||
<div className="mt-6 flex flex-wrap justify-center gap-8">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
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) => (
|
|
||||||
<a
|
|
||||||
key={img.href}
|
|
||||||
href={img.href}
|
|
||||||
className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0"
|
|
||||||
>
|
|
||||||
<img alt={img.alt} src={img.src} className="object-contain" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
125
app/routes/account.delete.tsx
Normal file
125
app/routes/account.delete.tsx
Normal file
|
|
@ -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<typeof action>();
|
||||||
|
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.errors?.password) {
|
||||||
|
passwordRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
open
|
||||||
|
aria-modal="true"
|
||||||
|
onClose={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Form method="DELETE" className="space-y-6">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete your account? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<p>Type your password to confirm </p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
ref={passwordRef}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-invalid={actionData?.errors?.password ? true : undefined}
|
||||||
|
aria-describedby="password-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.password ? (
|
||||||
|
<div className="pt-1 text-red-700" id="password-error">
|
||||||
|
{actionData.errors.password}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
formMethod="dialog"
|
||||||
|
onClick={() => {
|
||||||
|
window.history.back();
|
||||||
|
}}
|
||||||
|
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded border border-red-200 px-8 py-4 text-red-700 hover:bg-red-200 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-screen flex-col">
|
||||||
|
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
|
||||||
|
<h1 className="mr-auto text-3xl font-bold">
|
||||||
|
<Link to="/t">TranslAIte</Link>
|
||||||
|
</h1>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
|
<Form action="/logout" method="post">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-slate-600 px-4 py-2 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="m-auto w-full max-w-md px-8">
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="openAIKey"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Open AI key
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="openAIKey"
|
||||||
|
required
|
||||||
|
autoFocus={true}
|
||||||
|
name="openAIKey"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={
|
||||||
|
actionData?.user?.openAIKey ||
|
||||||
|
loaderData.user.openAIKey ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
aria-invalid={actionData?.errors?.openAIKey ? true : undefined}
|
||||||
|
aria-describedby="openAIKey-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.openAIKey ? (
|
||||||
|
<div className="pt-1 text-red-700" id="openAIKey-error">
|
||||||
|
{actionData.errors.openAIKey}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{actionData?.user?.openAIKey ? (
|
||||||
|
<div className="mt-8 rounded border border-green-400 bg-green-100 px-8 py-4 text-green-700">
|
||||||
|
<p className="text-lg font-bold">Account updated!</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<hr className="my-6" />
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<Link
|
||||||
|
to="delete"
|
||||||
|
className="rounded border border-red-200 px-8 py-4 text-red-700 hover:bg-red-200 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
export default function Join() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
export default function LoginPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const redirectTo = searchParams.get("redirectTo") || "/notes";
|
const redirectTo = searchParams.get("redirectTo") || "/t";
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const emailRef = useRef<HTMLInputElement>(null);
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
const passwordRef = useRef<HTMLInputElement>(null);
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { createTranslation } from "~/models/translation.server";
|
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) => {
|
export const action = async ({ request }: ActionArgs) => {
|
||||||
const userId = await requireUserId(request);
|
const user = await requireUser(request);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const lang = formData.get("lang");
|
const lang = formData.get("lang");
|
||||||
|
|
@ -15,27 +17,103 @@ export const action = async ({ request }: ActionArgs) => {
|
||||||
|
|
||||||
if (typeof lang !== "string" || lang.length === 0) {
|
if (typeof lang !== "string" || lang.length === 0) {
|
||||||
return json(
|
return json(
|
||||||
{ errors: { text: null, lang: "Lang is required" } },
|
{ errors: { text: null, lang: "Lang is required", result: null } },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof text !== "string" || text.length === 0) {
|
if (typeof text !== "string" || text.length === 0) {
|
||||||
return json(
|
return json(
|
||||||
{ errors: { text: "Text is required", lang: null } },
|
{ errors: { text: "Text is required", lang: null, result: null } },
|
||||||
{ status: 400 }
|
{ 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);
|
||||||
|
|
||||||
|
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}`);
|
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() {
|
export default function NewTranslationPage() {
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
const langRef = useRef<HTMLInputElement>(null);
|
const langRef = useRef<HTMLInputElement>(null);
|
||||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
|
@ -63,6 +141,7 @@ export default function NewTranslationPage() {
|
||||||
<input
|
<input
|
||||||
ref={langRef}
|
ref={langRef}
|
||||||
name="lang"
|
name="lang"
|
||||||
|
disabled={loaderData?.userHasOpenAIKey === false}
|
||||||
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
|
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
|
||||||
aria-invalid={actionData?.errors?.lang ? true : undefined}
|
aria-invalid={actionData?.errors?.lang ? true : undefined}
|
||||||
aria-errormessage={
|
aria-errormessage={
|
||||||
|
|
@ -84,6 +163,7 @@ export default function NewTranslationPage() {
|
||||||
ref={textRef}
|
ref={textRef}
|
||||||
name="text"
|
name="text"
|
||||||
rows={8}
|
rows={8}
|
||||||
|
disabled={loaderData?.userHasOpenAIKey === false}
|
||||||
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
|
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
|
||||||
aria-invalid={actionData?.errors?.text ? true : undefined}
|
aria-invalid={actionData?.errors?.text ? true : undefined}
|
||||||
aria-errormessage={
|
aria-errormessage={
|
||||||
|
|
@ -101,11 +181,30 @@ export default function NewTranslationPage() {
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={loaderData?.userHasOpenAIKey === false}
|
||||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
>
|
>
|
||||||
Translate
|
Translate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loaderData?.userHasOpenAIKey === false && (
|
||||||
|
<div className="rounded border border-red-200 bg-red-100 p-4 text-red-700">
|
||||||
|
<p>
|
||||||
|
You need to add your OpenAI API key to your account before you can
|
||||||
|
translate text.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Go to <Link to="/account">your account</Link> to add your key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.errors?.result && (
|
||||||
|
<div className="rounded border border-red-200 bg-red-100 p-4 text-red-700">
|
||||||
|
<p>{actionData.errors.result}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { LoaderArgs } from "@remix-run/node";
|
import type { LoaderArgs } from "@remix-run/node";
|
||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
|
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getTranslationsListItems } from "~/models/translation.server";
|
import { getTranslationsListItems } from "~/models/translation.server";
|
||||||
import { requireUserId } from "~/session.server";
|
import { requireUserId } from "~/session.server";
|
||||||
import { useUser } from "~/utils";
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderArgs) => {
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
|
|
@ -12,15 +12,32 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||||
return json({ translations });
|
return json({ translations });
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NotesPage() {
|
export default function TranslationsPage() {
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
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 (
|
return (
|
||||||
<div className="flex h-full min-h-screen flex-col">
|
<div className="flex h-full min-h-screen flex-col">
|
||||||
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
|
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
|
||||||
<h1 className="mr-auto text-3xl font-bold">
|
<h1 className="mr-auto text-3xl font-bold max-[375px]:hidden">
|
||||||
<Link to=".">Translations</Link>
|
<Link to=".">TranslAIte</Link>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -41,34 +58,65 @@ export default function NotesPage() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex h-full bg-white">
|
<main className="flex h-full bg-white">
|
||||||
<div className="h-full w-80 border-r bg-gray-50">
|
<aside
|
||||||
<Link to="new" className="block p-4 text-xl text-blue-500">
|
className="fixed left-0 z-10 h-full max-w-0 border-r bg-gray-50 sm:static sm:w-80"
|
||||||
+ New Translation
|
style={{
|
||||||
|
maxWidth: expanded ? "100%" : "3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="new"
|
||||||
|
className="m-2 block rounded bg-blue-500 px-8 py-4 text-xl text-white hover:bg-blue-600 active:bg-blue-700"
|
||||||
|
style={{
|
||||||
|
padding: expanded ? "0.75rem 1.5rem" : "0.5rem",
|
||||||
|
}}
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
{expanded ? "+ New Translation" : "+"}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<ul className={expanded ? "block" : "hidden"}>
|
||||||
{data.translations.length === 0 ? (
|
{data.translations.length === 0 ? (
|
||||||
|
<li>
|
||||||
<p className="p-4">No translations yet</p>
|
<p className="p-4">No translations yet</p>
|
||||||
|
</li>
|
||||||
) : (
|
) : (
|
||||||
<ol>
|
data.translations.map((t) => (
|
||||||
{data.translations.map((t) => (
|
|
||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
<NavLink
|
<NavLink
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
|
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
|
||||||
}
|
}
|
||||||
to={t.id}
|
to={t.id}
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
>
|
>
|
||||||
[{t.lang}] {t.text}
|
[{t.lang}] {t.text}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))
|
||||||
</ol>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
<div className="flex-1 p-6">
|
<div className="">
|
||||||
|
<button
|
||||||
|
className="block p-4 text-xl text-blue-500 "
|
||||||
|
onClick={() => {
|
||||||
|
_setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? "<<" : ">>"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-full flex-1 p-6"
|
||||||
|
style={{
|
||||||
|
paddingLeft: expanded ? "1.5rem" : "4.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"@remix-run/serve": "^1.19.2",
|
"@remix-run/serve": "^1.19.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"isbot": "^3.6.12",
|
"isbot": "^3.6.12",
|
||||||
|
"openai": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tiny-invariant": "^1.3.1"
|
"tiny-invariant": "^1.3.1"
|
||||||
|
|
|
||||||
22
yarn.lock
22
yarn.lock
|
|
@ -3774,6 +3774,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"axios@npm:^0.27.2":
|
||||||
version: 0.27.2
|
version: 0.27.2
|
||||||
resolution: "axios@npm:0.27.2"
|
resolution: "axios@npm:0.27.2"
|
||||||
|
|
@ -6381,7 +6390,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"follow-redirects@npm:^1.14.9":
|
"follow-redirects@npm:^1.14.8, follow-redirects@npm:^1.14.9":
|
||||||
version: 1.15.2
|
version: 1.15.2
|
||||||
resolution: "follow-redirects@npm:1.15.2"
|
resolution: "follow-redirects@npm:1.15.2"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
|
|
@ -9675,6 +9684,16 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"optionator@npm:^0.9.3":
|
||||||
version: 0.9.3
|
version: 0.9.3
|
||||||
resolution: "optionator@npm:0.9.3"
|
resolution: "optionator@npm:0.9.3"
|
||||||
|
|
@ -12094,6 +12113,7 @@ __metadata:
|
||||||
isbot: ^3.6.12
|
isbot: ^3.6.12
|
||||||
msw: ^1.2.2
|
msw: ^1.2.2
|
||||||
npm-run-all: ^4.1.5
|
npm-run-all: ^4.1.5
|
||||||
|
openai: ^3.3.0
|
||||||
postcss: ^8.4.24
|
postcss: ^8.4.24
|
||||||
prettier: 2.8.8
|
prettier: 2.8.8
|
||||||
prettier-plugin-tailwindcss: ^0.3.0
|
prettier-plugin-tailwindcss: ^0.3.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue