Compare commits

..

10 commits

25 changed files with 1100 additions and 764 deletions

View file

@ -10,26 +10,26 @@ RUN apt-get update && apt-get install -y openssl sqlite3
# Install all node_modules, including dev dependencies
FROM base as deps
WORKDIR /myapp
WORKDIR /translaite
ADD package.json package-lock.json .npmrc ./
ADD package.json .npmrc ./
RUN npm install --include=dev
# Setup production node_modules
FROM base as production-deps
WORKDIR /myapp
WORKDIR /translaite
COPY --from=deps /myapp/node_modules /myapp/node_modules
ADD package.json package-lock.json .npmrc ./
COPY --from=deps /translaite/node_modules /translaite/node_modules
ADD package.json .npmrc ./
RUN npm prune --omit=dev
# Build the app
FROM base as build
WORKDIR /myapp
WORKDIR /translaite
COPY --from=deps /myapp/node_modules /myapp/node_modules
COPY --from=deps /translaite/node_modules /translaite/node_modules
ADD prisma .
RUN npx prisma generate
@ -43,19 +43,25 @@ FROM base
ENV DATABASE_URL=file:/data/sqlite.db
ENV PORT="8080"
ENV NODE_ENV="production"
ENV SESSION_SECRET="${SESSION_SECRET:-8d17b3e56ceaf651e8763db9a80fc7f6}"
ENV ALLOW_USER_SIGNUP=0
# add shortcut for connecting to database CLI
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
WORKDIR /myapp
WORKDIR /translaite
COPY --from=production-deps /myapp/node_modules /myapp/node_modules
COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
COPY --from=production-deps /translaite/node_modules /translaite/node_modules
COPY --from=build /translaite/node_modules/.prisma /translaite/node_modules/.prisma
COPY --from=build /myapp/build /myapp/build
COPY --from=build /myapp/public /myapp/public
COPY --from=build /myapp/package.json /myapp/package.json
COPY --from=build /myapp/start.sh /myapp/start.sh
COPY --from=build /myapp/prisma /myapp/prisma
COPY --from=build /translaite/build /translaite/build
COPY --from=build /translaite/public /translaite/public
COPY --from=build /translaite/package.json /translaite/package.json
COPY --from=build /translaite/start.sh /translaite/start.sh
COPY --from=build /translaite/prisma /translaite/prisma
RUN chmod +x start.sh
ENTRYPOINT [ "./start.sh" ]
EXPOSE 8080

268
README.md
View file

@ -1,174 +1,132 @@
# Remix Indie Stack
# translAIte
![The Remix Indie Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf)
Translations app built with Remix, supports authentication. Uses ChatGPT to translate text.
Learn more about [Remix Stacks](https://remix.run/stacks).
After your first login, you will be prompted to enter your OpenAI API key. You can get one [here](https://platform.openai.com/account/api-keys).
```sh
npx create-remix@latest --template remix-run/indie-stack
Built for self-hosting: host it anywhere you want, and use it for free.
View on [DockerHub](https://hub.docker.com/r/nzambello/translaite).
## Table of contents
- [Pre-built Docker image](#pre-built-docker-image)
- [Docker compose](#docker-compose)
- [Custom deployments or development](#custom-deployment-or-development)
- [Tech stack](#tech-stack)
- [Running locally](#running-locally)
- [Running with Docker](#running-with-docker)
- [Multi-platform docker image](#multi-platform-docker-image)
- [License](#license)
## Pre-built Docker Image
```bash
docker pull nzambello/translaite
```
## What's in the stack
If you want to use the pre-built Docker image, you can run it with:
- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/)
- Production-ready [SQLite Database](https://sqlite.org)
- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks)
- [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments
- Email/Password Authentication with [cookie-based sessions](https://remix.run/utils/sessions#md-createcookiesessionstorage)
- Database ORM with [Prisma](https://prisma.io)
- Styling with [Tailwind](https://tailwindcss.com/)
- End-to-end testing with [Cypress](https://cypress.io)
- Local third party request mocking with [MSW](https://mswjs.io)
- Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com)
- Code formatting with [Prettier](https://prettier.io)
- Linting with [ESLint](https://eslint.org)
- Static Types with [TypeScript](https://typescriptlang.org)
Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own.
## Quickstart
Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up and Fly pre-installed
[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/remix-run/indie-stack/tree/main)
## Development
- Initial setup:
```sh
npm run setup
```
- Start dev server:
```sh
npm run dev
```
This starts your app in development mode, rebuilding assets on file changes.
The database seed script creates a new user with some data you can use to get started:
- Email: `rachel@remix.run`
- Password: `racheliscool`
### Relevant code:
This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes.
- creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts)
- user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts)
- creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts)
## Deployment
This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments.
Prior to your first deployment, you'll need to do a few things:
- [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/)
- Sign up and log in to Fly
```sh
fly auth signup
```
> **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser.
- Create two apps on Fly, one for staging and one for production:
```sh
fly apps create translaite-2ae4
fly apps create translaite-2ae4-staging
```
> **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy.
- Initialize Git.
```sh
git init
```
- Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
```sh
git remote add origin <ORIGIN_URL>
```
- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`.
- Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:
```sh
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app translaite-2ae4
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app translaite-2ae4-staging
```
If you don't have openssl installed, you can also use [1Password](https://1password.com/password-generator) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret.
- Create a persistent volume for the sqlite database for both your staging and production environments. Run the following:
```sh
fly volumes create data --size 1 --app translaite-2ae4
fly volumes create data --size 1 --app translaite-2ae4-staging
```
Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment.
### Connecting to your database
The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`.
### Getting Help with Deployment
If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions.
## GitHub Actions
We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging.
## Testing
### Cypress
We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes.
We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically.
To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above.
We have a utility for testing authenticated features without having to go through the login flow:
```ts
cy.login();
// you are now logged in as a new user
```bash
docker run -d -p 8080:8080 -v /path/to/data:/data/data.db nzambello/translaite
```
We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file:
If you want to use different defaults, you can build your own image. See [Running with docker](#running-with-docker)
```ts
afterEach(() => {
cy.cleanupUser();
});
### Docker compose
Basic example:
```yaml
version: "3.8"
services:
translaite:
image: nzambello/translaite
container_name: translaite
restart: always
ports:
- 8080:8080
volumes:
- ./dockerData/translaite:/data # Path to data for DB persistence
```
That way, we can keep your local db clean and keep your tests isolated from one another.
Example of docker-compose.yml with [Traefik](https://traefik.io/) as reverse proxy:
### Vitest
```yaml
translaite:
depends_on:
- watchtower
image: nzambello/translaite
container_name: translaite
restart: always
volumes:
- /dockerData/translaite:/data # Path to data for DB persistence
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "traefik.enable=true"
- "traefik.http.routers.translaite.rule=Host(`translate.YOURDOMAIN.com`)" # change it to your preferences
- "traefik.http.routers.translaite.entrypoints=websecure"
- "traefik.http.routers.translaite.tls.certresolver=letsencrypt"
- "traefik.http.routers.translaite.service=translaite-service"
- "traefik.http.services.translaite-service.loadbalancer.server.port=8080"
```
For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom).
## Custom deployment or development
### Type Checking
### Tech Stack
This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`.
- [Remix](https://remix.run)
- [Prisma](https://prisma.io)
- [SQLite](https://sqlite.org)
- [Tailwind](https://tailwindcss.com)
- [Docker](https://docker.com)
### Linting
### Running Locally
This project uses ESLint for linting. That is configured in `.eslintrc.js`.
```bash
# Clone the repo
git clone https://github.com/nzambello/translaite.git
cd translaite
### Formatting
# Install dependencies
yarn install
We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project.
# Setup .env
cp .env.example .env
vim .env
# Start the app
yarn dev
```
### Running with Docker
```bash
# Clone the repo
git clone https://github.com/nzambello/translaite.git
cd translaite
# Setup .env
cp .env.example .env
vim .env
# Build the image
docker built -t translaite .
# Start the app
docker run -p 127.0.0.1:8080:8080 translaite
```
### Multi-platform Docker image
```bash
docker buildx create --name mybuilder --driver docker-container --bootstrap --use # create a new builder and switch to it using a single command.
docker buildx build --platform linux/amd64,linux/arm64 -t nzambello/translaite:latest --push .
```
## License
[Nicola Zambello](https://github.com/nzambello) © 2023
[GNU GPLv3](https://github.com/nzambello/translaite/raw/main/LICENSE)

12
app/config.server.ts Normal file
View file

@ -0,0 +1,12 @@
import { countUsers } from "./models/user.server";
export const ALLOW_USER_SIGNUP = process.env.ALLOW_USER_SIGNUP === "1" || null;
export const isSignupAllowed = async () => {
let isFirstUser = (await countUsers()) === 0;
if (isFirstUser) {
return true;
}
return !!ALLOW_USER_SIGNUP;
};

View file

@ -1,52 +0,0 @@
import type { User, Note } from "@prisma/client";
import { prisma } from "~/db.server";
export function getNote({
id,
userId,
}: Pick<Note, "id"> & {
userId: User["id"];
}) {
return prisma.note.findFirst({
select: { id: true, body: true, title: true },
where: { id, userId },
});
}
export function getNoteListItems({ userId }: { userId: User["id"] }) {
return prisma.note.findMany({
where: { userId },
select: { id: true, title: true },
orderBy: { updatedAt: "desc" },
});
}
export function createNote({
body,
title,
userId,
}: Pick<Note, "body" | "title"> & {
userId: User["id"];
}) {
return prisma.note.create({
data: {
title,
body,
user: {
connect: {
id: userId,
},
},
},
});
}
export function deleteNote({
id,
userId,
}: Pick<Note, "id"> & { userId: User["id"] }) {
return prisma.note.deleteMany({
where: { id, userId },
});
}

View file

@ -0,0 +1,52 @@
import type { User, Translation } from "@prisma/client";
import { prisma } from "~/db.server";
export function getTranslation({
id,
userId,
}: Pick<Translation, "id"> & {
userId: User["id"];
}) {
return prisma.translation.findFirst({
where: { id, userId },
});
}
export function getTranslationsListItems({ userId }: { userId: User["id"] }) {
return prisma.translation.findMany({
where: { userId },
orderBy: { updatedAt: "desc" },
});
}
export function createTranslation({
lang,
text,
result,
userId,
}: Pick<Translation, "lang" | "text" | "result"> & {
userId: User["id"];
}) {
return prisma.translation.create({
data: {
lang,
text,
result,
user: {
connect: {
id: userId,
},
},
},
});
}
export function deleteTranslation({
id,
userId,
}: Pick<Translation, "id"> & { userId: User["id"] }) {
return prisma.translation.deleteMany({
where: { id, userId },
});
}

View file

@ -5,6 +5,10 @@ import { prisma } from "~/db.server";
export type { User } from "@prisma/client";
export async function countUsers() {
return prisma.user.count();
}
export async function getUserById(id: User["id"]) {
return prisma.user.findUnique({ where: { id } });
}
@ -28,6 +32,34 @@ export async function createUser(email: User["email"], password: string) {
});
}
export async function updateUser(
email: User["email"],
data: {
openAIKey?: User["openAIKey"];
password?: string;
}
) {
let userData: { [key: string]: any } = {};
if (data.openAIKey) {
userData.openAIKey = data.openAIKey;
}
if (data.password) {
const hashedPassword = await bcrypt.hash(data.password, 10);
userData.password = {
create: {
hash: hashedPassword,
},
};
}
if (Object.values(userData).length === 0) return;
return prisma.user.update({
where: { email },
data: userData,
});
}
export async function deleteUserByEmail(email: User["email"]) {
return prisma.user.delete({ where: { email } });
}

View file

@ -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 (
<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="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="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">
<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">
Indie Stack
Transl
<span className="uppercase text-yellow-500 drop-shadow-md">
AI
</span>
te
</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">
{user ? (
<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"
>
View Notes for {user.email}
Translate now
</Link>
) : (
<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>
<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 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>
</main>
);

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

125
app/routes/account.tsx Normal file
View file

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

View file

@ -1,16 +1,30 @@
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams,
} from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUser, getUserByEmail, countUsers } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils";
import { isSignupAllowed } from "~/config.server";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
const isFirstUser = (await countUsers()) === 0;
if (!(await isSignupAllowed())) {
return redirect("/login");
}
return json({ isFirstUser });
};
export const action = async ({ request }: ActionArgs) => {
@ -19,6 +33,21 @@ export const action = async ({ request }: ActionArgs) => {
const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
const isFirstUser = (await countUsers()) === 0;
if (!isSignupAllowed() && !isFirstUser) {
return json(
{
errors: {
email: "User signup is disabled",
password: null,
confirmPassword: null,
},
},
{ status: 418 }
);
}
if (!validateEmail(email)) {
return json(
{ errors: { email: "Email is invalid", password: null } },
@ -63,12 +92,13 @@ 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();
const redirectTo = searchParams.get("redirectTo") ?? undefined;
const actionData = useActionData<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
@ -145,20 +175,22 @@ export default function Join() {
>
Create Account
</button>
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/login",
search: searchParams.toString(),
}}
>
Log in
</Link>
{!loaderData.isFirstUser && (
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/login",
search: searchParams.toString(),
}}
>
Log in
</Link>
</div>
</div>
</div>
)}
</Form>
</div>
</div>

View file

@ -1,16 +1,29 @@
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams,
} from "@remix-run/react";
import { useEffect, useRef } from "react";
import { isSignupAllowed } from "~/config.server";
import { verifyLogin } from "~/models/user.server";
import { countUsers, verifyLogin } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
const isFirstUser = (await countUsers()) === 0;
if (isFirstUser) return redirect("/signup");
return json({
ALLOW_USER_SIGNUP: await isSignupAllowed(),
});
};
export const action = async ({ request }: ActionArgs) => {
@ -58,12 +71,13 @@ 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<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
@ -140,7 +154,7 @@ export default function LoginPage() {
>
Log in
</button>
<div className="flex items-center justify-between">
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex items-center">
<input
id="remember"
@ -155,18 +169,20 @@ export default function LoginPage() {
Remember me
</label>
</div>
<div className="text-center text-sm text-gray-500">
Don't have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/join",
search: searchParams.toString(),
}}
>
Sign up
</Link>
</div>
{!!loaderData?.ALLOW_USER_SIGNUP && (
<div className="mt-8 text-center text-sm text-gray-500 md:mt-0">
Don't have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/join",
search: searchParams.toString(),
}}
>
Sign up
</Link>
</div>
)}
</div>
</Form>
</div>

View file

@ -1,12 +0,0 @@
import { Link } from "@remix-run/react";
export default function NoteIndexPage() {
return (
<p>
No note selected. Select a note on the left, or{" "}
<Link to="new" className="text-blue-500 underline">
create a new note.
</Link>
</p>
);
}

View file

@ -1,109 +0,0 @@
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createNote } from "~/models/note.server";
import { requireUserId } from "~/session.server";
export const action = async ({ request }: ActionArgs) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const title = formData.get("title");
const body = formData.get("body");
if (typeof title !== "string" || title.length === 0) {
return json(
{ errors: { body: null, title: "Title is required" } },
{ status: 400 }
);
}
if (typeof body !== "string" || body.length === 0) {
return json(
{ errors: { body: "Body is required", title: null } },
{ status: 400 }
);
}
const note = await createNote({ body, title, userId });
return redirect(`/notes/${note.id}`);
};
export default function NewNotePage() {
const actionData = useActionData<typeof action>();
const titleRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (actionData?.errors?.title) {
titleRef.current?.focus();
} else if (actionData?.errors?.body) {
bodyRef.current?.focus();
}
}, [actionData]);
return (
<Form
method="post"
style={{
display: "flex",
flexDirection: "column",
gap: 8,
width: "100%",
}}
>
<div>
<label className="flex w-full flex-col gap-1">
<span>Title: </span>
<input
ref={titleRef}
name="title"
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
aria-invalid={actionData?.errors?.title ? true : undefined}
aria-errormessage={
actionData?.errors?.title ? "title-error" : undefined
}
/>
</label>
{actionData?.errors?.title ? (
<div className="pt-1 text-red-700" id="title-error">
{actionData.errors.title}
</div>
) : null}
</div>
<div>
<label className="flex w-full flex-col gap-1">
<span>Body: </span>
<textarea
ref={bodyRef}
name="body"
rows={8}
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
aria-invalid={actionData?.errors?.body ? true : undefined}
aria-errormessage={
actionData?.errors?.body ? "body-error" : undefined
}
/>
</label>
{actionData?.errors?.body ? (
<div className="pt-1 text-red-700" id="body-error">
{actionData.errors.body}
</div>
) : null}
</div>
<div className="text-right">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Save
</button>
</div>
</Form>
);
}

View file

@ -1,70 +0,0 @@
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
import { getNoteListItems } from "~/models/note.server";
import { requireUserId } from "~/session.server";
import { useUser } from "~/utils";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await requireUserId(request);
const noteListItems = await getNoteListItems({ userId });
return json({ noteListItems });
};
export default function NotesPage() {
const data = useLoaderData<typeof loader>();
const user = useUser();
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="text-3xl font-bold">
<Link to=".">Notes</Link>
</h1>
<p>{user.email}</p>
<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>
</header>
<main className="flex h-full bg-white">
<div className="h-full w-80 border-r bg-gray-50">
<Link to="new" className="block p-4 text-xl text-blue-500">
+ New Note
</Link>
<hr />
{data.noteListItems.length === 0 ? (
<p className="p-4">No notes yet</p>
) : (
<ol>
{data.noteListItems.map((note) => (
<li key={note.id}>
<NavLink
className={({ isActive }) =>
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
}
to={note.id}
>
📝 {note.title}
</NavLink>
</li>
))}
</ol>
)}
</div>
<div className="flex-1 p-6">
<Outlet />
</div>
</main>
</div>
);
}

View file

@ -8,41 +8,48 @@ import {
} from "@remix-run/react";
import invariant from "tiny-invariant";
import { deleteNote, getNote } from "~/models/note.server";
import { deleteTranslation, getTranslation } from "~/models/translation.server";
import { requireUserId } from "~/session.server";
export const loader = async ({ params, request }: LoaderArgs) => {
const userId = await requireUserId(request);
invariant(params.noteId, "noteId not found");
invariant(params.translationId, "translationId not found");
const note = await getNote({ id: params.noteId, userId });
if (!note) {
const translation = await getTranslation({
id: params.translationId,
userId,
});
if (!translation) {
throw new Response("Not Found", { status: 404 });
}
return json({ note });
return json({ translation });
};
export const action = async ({ params, request }: ActionArgs) => {
const userId = await requireUserId(request);
invariant(params.noteId, "noteId not found");
invariant(params.translationId, "translationId not found");
await deleteNote({ id: params.noteId, userId });
await deleteTranslation({ id: params.translationId, userId });
return redirect("/notes");
return redirect("/t");
};
export default function NoteDetailsPage() {
export default function TranslationDetailsPage() {
const data = useLoaderData<typeof loader>();
return (
<div>
<h3 className="text-2xl font-bold">{data.note.title}</h3>
<p className="py-6">{data.note.body}</p>
<p className="text-xl font-bold">To: {data.translation.lang}</p>
<p className="py-6">{data.translation.text}</p>
<hr className="my-4" />
<p>
<strong>Result:</strong>
</p>
<p className="py-6">{data.translation.result}</p>
<Form method="post">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600 focus:bg-red-400"
>
Delete
</button>
@ -63,7 +70,7 @@ export function ErrorBoundary() {
}
if (error.status === 404) {
return <div>Note not found</div>;
return <div>Translation not found</div>;
}
return <div>An unexpected error occurred: {error.statusText}</div>;

13
app/routes/t._index.tsx Normal file
View file

@ -0,0 +1,13 @@
import { Link } from "@remix-run/react";
export default function TranslationsIndexPage() {
return (
<p>
No translation selected. Select one the left, or{" "}
<Link to="new" className="text-blue-500 underline">
start a new translation
</Link>
.
</p>
);
}

250
app/routes/t.new.tsx Normal file
View file

@ -0,0 +1,250 @@
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
Link,
useActionData,
useLoaderData,
useNavigation,
} from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createTranslation } from "~/models/translation.server";
import { requireUser } from "~/session.server";
import { Configuration, OpenAIApi } from "openai";
export const action = async ({ request }: ActionArgs) => {
const user = await requireUser(request);
const formData = await request.formData();
const lang = formData.get("lang");
const text = formData.get("text");
if (typeof lang !== "string" || lang.length === 0) {
return json(
{ 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, result: null } },
{ status: 400 }
);
}
if (!user.openAIKey?.length) {
return redirect("/account");
}
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}`);
} 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<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const langRef = useRef<HTMLInputElement>(null);
const textRef = useRef<HTMLTextAreaElement>(null);
const navigation = useNavigation();
useEffect(() => {
if (actionData?.errors?.lang) {
langRef.current?.focus();
} else if (actionData?.errors?.text) {
textRef.current?.focus();
}
}, [actionData]);
return (
<Form
method="post"
style={{
display: "flex",
flexDirection: "column",
gap: 8,
width: "100%",
}}
>
<div>
<label className="flex w-full flex-col gap-1">
<span>Translate to: </span>
<input
ref={langRef}
name="lang"
required
disabled={
loaderData?.userHasOpenAIKey === false ||
navigation.state === "submitting"
}
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
aria-invalid={actionData?.errors?.lang ? true : undefined}
aria-errormessage={
actionData?.errors?.lang ? "lang-error" : undefined
}
/>
</label>
{actionData?.errors?.lang ? (
<div className="pt-1 text-red-700" id="lang-error">
{actionData.errors.lang}
</div>
) : null}
</div>
<div>
<label className="flex w-full flex-col gap-1">
<span>Text: </span>
<textarea
ref={textRef}
name="text"
required
rows={8}
disabled={
loaderData?.userHasOpenAIKey === false ||
navigation.state === "submitting"
}
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-errormessage={
actionData?.errors?.text ? "text-error" : undefined
}
/>
</label>
{actionData?.errors?.text ? (
<div className="pt-1 text-red-700" id="text-error">
{actionData.errors.text}
</div>
) : null}
</div>
<div className="text-right">
<button
type="submit"
disabled={
loaderData?.userHasOpenAIKey === false ||
navigation.state === "submitting"
}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
{navigation.state === "submitting" && (
<svg
className="mr-2 inline-block h-4 w-4 animate-spin text-white"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2
5.291A7.962 7.962 0 014 12H0c0 3.042
1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
Translate
</button>
</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>
);
}

134
app/routes/t.tsx Normal file
View file

@ -0,0 +1,134 @@
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";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await requireUserId(request);
const translations = await getTranslationsListItems({ userId });
return json({ translations });
};
export default function TranslationsPage() {
const data = useLoaderData<typeof loader>();
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 (
<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 max-[375px]:hidden">
<Link to=".">TranslAIte</Link>
</h1>
<div className="ml-auto flex items-center">
<Link
to="/account"
className="mr-2 rounded bg-slate-600 px-4 py-2 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
>
Account
</Link>
<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>
<main className="flex h-full bg-white">
<aside
className="fixed left-0 z-10 h-full max-w-0 border-r bg-gray-50 sm:static sm:w-80"
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>
<hr />
<ul
className={`max-h-full overflow-scroll ${
expanded ? "block" : "hidden"
}`}
style={{
maxHeight: "calc(100vh - 68px - 1px - 60px - 72px)",
}}
>
{data.translations.length === 0 ? (
<li>
<p className="p-4">No translations yet</p>
</li>
) : (
data.translations.map((t) => (
<li key={t.id}>
<NavLink
className={({ isActive }) =>
`block border-b p-4 text-sm ${isActive ? "bg-white" : ""}`
}
to={t.id}
onClick={() => setExpanded(false)}
>
<p className="line-clamp-3">
[{t.lang}] {t.text}
</p>
</NavLink>
</li>
))
)}
</ul>
<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 />
</div>
</main>
</div>
);
}

View file

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

View file

@ -14,14 +14,15 @@ CREATE TABLE "Password" (
);
-- CreateTable
CREATE TABLE "Note" (
CREATE TABLE "Translation" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"lang" TEXT NOT NULL,
"text" TEXT NOT NULL,
"result" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
CONSTRAINT "Translation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "openAIKey" TEXT;

View file

@ -14,8 +14,10 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
password Password?
notes Note[]
password Password?
translations Translation[]
openAIKey String?
}
model Password {
@ -25,10 +27,11 @@ model Password {
userId String @unique
}
model Note {
id String @id @default(cuid())
title String
body String
model Translation {
id String @id @default(cuid())
lang String
text String
result String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View file

@ -4,14 +4,14 @@ import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function seed() {
const email = "rachel@remix.run";
const email = "nicola@nzambello.dev";
// cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => {
// no worries if it doesn't exist yet
});
const hashedPassword = await bcrypt.hash("racheliscool", 10);
const hashedPassword = await bcrypt.hash("nzambello.dev", 10);
const user = await prisma.user.create({
data: {
@ -24,18 +24,20 @@ async function seed() {
},
});
await prisma.note.create({
await prisma.translation.create({
data: {
title: "My first note",
body: "Hello, world!",
lang: "italian",
text: "Hello, world!",
result: "Ciao, mondo!",
userId: user.id,
},
});
await prisma.note.create({
await prisma.translation.create({
data: {
title: "My second note",
body: "Hello, world!",
lang: "spanish",
text: "Hello, world!",
result: "¡Hola Mundo!",
userId: user.id,
},
});

View file

@ -1,4 +1,6 @@
#!/bin/sh -ex
#!/bin/sh
set -ex
# This file is how Fly starts the server (configured in fly.toml). Before starting
# the server though, we need to run any prisma migrations that haven't yet been
@ -6,12 +8,12 @@
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
# allocate swap space
fallocate -l 512M /swapfile
chmod 0600 /swapfile
mkswap /swapfile
echo 10 > /proc/sys/vm/swappiness
swapon /swapfile
echo 1 > /proc/sys/vm/overcommit_memory
# fallocate -l 512M /swapfile
# chmod 0600 /swapfile
# mkswap /swapfile
# echo 10 > /proc/sys/vm/swappiness
# swapon /swapfile
# echo 1 > /proc/sys/vm/overcommit_memory
npx prisma migrate deploy
npm run start
yarn start

257
yarn.lock
View file

@ -2975,7 +2975,7 @@ __metadata:
languageName: node
linkType: hard
"@types/unist@npm:^2, @types/unist@npm:^2.0.0, @types/unist@npm:^2.0.2":
"@types/unist@npm:^2, @types/unist@npm:^2.0.0":
version: 2.0.7
resolution: "@types/unist@npm:2.0.7"
checksum: b97a219554e83431f19a93ff113306bf0512909292815e8f32964e47d041c505af1aaa2a381c23e137c4c0b962fad58d4ce9c5c3256642921a466be43c1fc715
@ -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"
@ -4258,13 +4267,6 @@ __metadata:
languageName: node
linkType: hard
"character-entities-legacy@npm:^1.0.0":
version: 1.1.4
resolution: "character-entities-legacy@npm:1.1.4"
checksum: fe03a82c154414da3a0c8ab3188e4237ec68006cbcd681cf23c7cfb9502a0e76cd30ab69a2e50857ca10d984d57de3b307680fff5328ccd427f400e559c3a811
languageName: node
linkType: hard
"character-entities-legacy@npm:^3.0.0":
version: 3.0.0
resolution: "character-entities-legacy@npm:3.0.0"
@ -4272,13 +4274,6 @@ __metadata:
languageName: node
linkType: hard
"character-entities@npm:^1.0.0":
version: 1.2.4
resolution: "character-entities@npm:1.2.4"
checksum: e1545716571ead57beac008433c1ff69517cd8ca5b336889321c5b8ff4a99c29b65589a701e9c086cda8a5e346a67295e2684f6c7ea96819fe85cbf49bf8686d
languageName: node
linkType: hard
"character-entities@npm:^2.0.0":
version: 2.0.2
resolution: "character-entities@npm:2.0.2"
@ -4286,13 +4281,6 @@ __metadata:
languageName: node
linkType: hard
"character-reference-invalid@npm:^1.0.0":
version: 1.1.4
resolution: "character-reference-invalid@npm:1.1.4"
checksum: 20274574c70e05e2f81135f3b93285536bc8ff70f37f0809b0d17791a832838f1e49938382899ed4cb444e5bbd4314ca1415231344ba29f4222ce2ccf24fea0b
languageName: node
linkType: hard
"character-reference-invalid@npm:^2.0.0":
version: 2.0.1
resolution: "character-reference-invalid@npm:2.0.1"
@ -5737,17 +5725,6 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-markdown@npm:^3.0.0":
version: 3.0.1
resolution: "eslint-plugin-markdown@npm:3.0.1"
dependencies:
mdast-util-from-markdown: ^0.8.5
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
checksum: 91233d35777517a290377233eecbdbbe17d583f40b11b9adf371d051a98012ad6b540967aae59c2786fb8b66aa7c1abb27108947034b1f0f3e0df1c8aae9f2e7
languageName: node
linkType: hard
"eslint-plugin-node@npm:^11.1.0":
version: 11.1.0
resolution: "eslint-plugin-node@npm:11.1.0"
@ -5764,15 +5741,6 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-prefer-let@npm:^3.0.1":
version: 3.0.1
resolution: "eslint-plugin-prefer-let@npm:3.0.1"
dependencies:
requireindex: ~1.2.0
checksum: c6b2bcd0e192d1875e986364fb3ec8421da2126b216fab418dff89249aeb4eb28fca8cd17f818f5ae9753864733299524b1ef9bb74305ed974a22d5241cdd752
languageName: node
linkType: hard
"eslint-plugin-react-hooks@npm:^4.6.0":
version: 4.6.0
resolution: "eslint-plugin-react-hooks@npm:4.6.0"
@ -6422,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:
@ -7291,60 +7259,6 @@ __metadata:
languageName: node
linkType: hard
"indie-stack-template@workspace:.":
version: 0.0.0-use.local
resolution: "indie-stack-template@workspace:."
dependencies:
"@faker-js/faker": ^8.0.2
"@prisma/client": ^4.16.1
"@remix-run/css-bundle": ^1.19.2
"@remix-run/dev": ^1.19.2
"@remix-run/eslint-config": ^1.19.2
"@remix-run/node": ^1.19.2
"@remix-run/react": ^1.19.2
"@remix-run/serve": ^1.19.2
"@testing-library/cypress": ^9.0.0
"@testing-library/jest-dom": ^5.16.5
"@types/bcryptjs": ^2.4.2
"@types/eslint": ^8.40.2
"@types/node": ^18.16.18
"@types/react": ^18.2.14
"@types/react-dom": ^18.2.6
"@vitejs/plugin-react": ^4.0.1
"@vitest/coverage-v8": ^0.32.2
autoprefixer: ^10.4.14
bcryptjs: ^2.4.3
binode: ^1.0.5
cookie: ^0.5.0
cross-env: ^7.0.3
cypress: ^12.16.0
eslint: ^8.43.0
eslint-config-prettier: ^8.8.0
eslint-plugin-cypress: ^2.13.3
eslint-plugin-markdown: ^3.0.0
eslint-plugin-prefer-let: ^3.0.1
happy-dom: ^9.20.3
isbot: ^3.6.12
msw: ^1.2.2
npm-run-all: ^4.1.5
postcss: ^8.4.24
prettier: 2.8.8
prettier-plugin-tailwindcss: ^0.3.0
prisma: ^4.16.1
react: ^18.2.0
react-dom: ^18.2.0
start-server-and-test: ^2.0.0
tailwindcss: ^3.3.2
tiny-invariant: ^1.3.1
ts-node: ^10.9.1
tsconfig-paths: ^4.2.0
typescript: ^5.1.3
vite: ^4.3.9
vite-tsconfig-paths: ^3.6.0
vitest: ^0.32.2
languageName: unknown
linkType: soft
"infer-owner@npm:^1.0.4":
version: 1.0.4
resolution: "infer-owner@npm:1.0.4"
@ -7438,13 +7352,6 @@ __metadata:
languageName: node
linkType: hard
"is-alphabetical@npm:^1.0.0":
version: 1.0.4
resolution: "is-alphabetical@npm:1.0.4"
checksum: 6508cce44fd348f06705d377b260974f4ce68c74000e7da4045f0d919e568226dc3ce9685c5a2af272195384df6930f748ce9213fc9f399b5d31b362c66312cb
languageName: node
linkType: hard
"is-alphabetical@npm:^2.0.0":
version: 2.0.1
resolution: "is-alphabetical@npm:2.0.1"
@ -7452,16 +7359,6 @@ __metadata:
languageName: node
linkType: hard
"is-alphanumerical@npm:^1.0.0":
version: 1.0.4
resolution: "is-alphanumerical@npm:1.0.4"
dependencies:
is-alphabetical: ^1.0.0
is-decimal: ^1.0.0
checksum: e2e491acc16fcf5b363f7c726f666a9538dba0a043665740feb45bba1652457a73441e7c5179c6768a638ed396db3437e9905f403644ec7c468fb41f4813d03f
languageName: node
linkType: hard
"is-alphanumerical@npm:^2.0.0":
version: 2.0.1
resolution: "is-alphanumerical@npm:2.0.1"
@ -7571,13 +7468,6 @@ __metadata:
languageName: node
linkType: hard
"is-decimal@npm:^1.0.0":
version: 1.0.4
resolution: "is-decimal@npm:1.0.4"
checksum: ed483a387517856dc395c68403a10201fddcc1b63dc56513fbe2fe86ab38766120090ecdbfed89223d84ca8b1cd28b0641b93cb6597b6e8f4c097a7c24e3fb96
languageName: node
linkType: hard
"is-decimal@npm:^2.0.0":
version: 2.0.1
resolution: "is-decimal@npm:2.0.1"
@ -7649,13 +7539,6 @@ __metadata:
languageName: node
linkType: hard
"is-hexadecimal@npm:^1.0.0":
version: 1.0.4
resolution: "is-hexadecimal@npm:1.0.4"
checksum: a452e047587b6069332d83130f54d30da4faf2f2ebaa2ce6d073c27b5703d030d58ed9e0b729c8e4e5b52c6f1dab26781bb77b7bc6c7805f14f320e328ff8cd5
languageName: node
linkType: hard
"is-hexadecimal@npm:^2.0.0":
version: 2.0.1
resolution: "is-hexadecimal@npm:2.0.1"
@ -8585,19 +8468,6 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-from-markdown@npm:^0.8.5":
version: 0.8.5
resolution: "mdast-util-from-markdown@npm:0.8.5"
dependencies:
"@types/mdast": ^3.0.0
mdast-util-to-string: ^2.0.0
micromark: ~2.11.0
parse-entities: ^2.0.0
unist-util-stringify-position: ^2.0.0
checksum: 5a9d0d753a42db763761e874c22365d0c7c9934a5a18b5ff76a0643610108a208a041ffdb2f3d3dd1863d3d915225a4020a0aade282af0facfd0df110601eee6
languageName: node
linkType: hard
"mdast-util-from-markdown@npm:^1.0.0":
version: 1.3.1
resolution: "mdast-util-from-markdown@npm:1.3.1"
@ -8725,13 +8595,6 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-to-string@npm:^2.0.0":
version: 2.0.0
resolution: "mdast-util-to-string@npm:2.0.0"
checksum: 0b2113ada10e002fbccb014170506dabe2f2ddacaacbe4bc1045c33f986652c5a162732a2c057c5335cdb58419e2ad23e368e5be226855d4d4e280b81c4e9ec2
languageName: node
linkType: hard
"mdast-util-to-string@npm:^3.0.0, mdast-util-to-string@npm:^3.1.0":
version: 3.2.0
resolution: "mdast-util-to-string@npm:3.2.0"
@ -9155,16 +9018,6 @@ __metadata:
languageName: node
linkType: hard
"micromark@npm:~2.11.0":
version: 2.11.4
resolution: "micromark@npm:2.11.4"
dependencies:
debug: ^4.0.0
parse-entities: ^2.0.0
checksum: f8a5477d394908a5d770227aea71657a76423d420227c67ea0699e659a5f62eb39d504c1f7d69ec525a6af5aaeb6a7bffcdba95614968c03d41d3851edecb0d6
languageName: node
linkType: hard
"micromatch@npm:^4.0.4, micromatch@npm:^4.0.5":
version: 4.0.5
resolution: "micromatch@npm:4.0.5"
@ -9831,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"
@ -9976,20 +9839,6 @@ __metadata:
languageName: node
linkType: hard
"parse-entities@npm:^2.0.0":
version: 2.0.0
resolution: "parse-entities@npm:2.0.0"
dependencies:
character-entities: ^1.0.0
character-entities-legacy: ^1.0.0
character-reference-invalid: ^1.0.0
is-alphanumerical: ^1.0.0
is-decimal: ^1.0.0
is-hexadecimal: ^1.0.0
checksum: 7addfd3e7d747521afac33c8121a5f23043c6973809756920d37e806639b4898385d386fcf4b3c8e2ecf1bc28aac5ae97df0b112d5042034efbe80f44081ebce
languageName: node
linkType: hard
"parse-entities@npm:^4.0.0":
version: 4.0.1
resolution: "parse-entities@npm:4.0.1"
@ -11012,7 +10861,7 @@ __metadata:
languageName: node
linkType: hard
"requireindex@npm:^1.2.0, requireindex@npm:~1.2.0":
"requireindex@npm:^1.2.0":
version: 1.2.0
resolution: "requireindex@npm:1.2.0"
checksum: 50d8b10a1ff1fdf6aea7a1870bc7bd238b0fb1917d8d7ca17fd03afc38a65dcd7a8a4eddd031f89128b5f0065833d5c92c4fef67f2c04e8624057fe626c9cf94
@ -12230,6 +12079,59 @@ __metadata:
languageName: node
linkType: hard
"translaite-2ae4@workspace:.":
version: 0.0.0-use.local
resolution: "translaite-2ae4@workspace:."
dependencies:
"@faker-js/faker": ^8.0.2
"@prisma/client": ^4.16.1
"@remix-run/css-bundle": ^1.19.2
"@remix-run/dev": ^1.19.2
"@remix-run/eslint-config": ^1.19.2
"@remix-run/node": ^1.19.2
"@remix-run/react": ^1.19.2
"@remix-run/serve": ^1.19.2
"@testing-library/cypress": ^9.0.0
"@testing-library/jest-dom": ^5.16.5
"@types/bcryptjs": ^2.4.2
"@types/eslint": ^8.40.2
"@types/node": ^18.16.18
"@types/react": ^18.2.14
"@types/react-dom": ^18.2.6
"@vitejs/plugin-react": ^4.0.1
"@vitest/coverage-v8": ^0.32.2
autoprefixer: ^10.4.14
bcryptjs: ^2.4.3
binode: ^1.0.5
cookie: ^0.5.0
cross-env: ^7.0.3
cypress: ^12.16.0
eslint: ^8.43.0
eslint-config-prettier: ^8.8.0
eslint-plugin-cypress: ^2.13.3
happy-dom: ^9.20.3
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
prisma: ^4.16.1
react: ^18.2.0
react-dom: ^18.2.0
start-server-and-test: ^2.0.0
tailwindcss: ^3.3.2
tiny-invariant: ^1.3.1
ts-node: ^10.9.1
tsconfig-paths: ^4.2.0
typescript: ^5.1.3
vite: ^4.3.9
vite-tsconfig-paths: ^3.6.0
vitest: ^0.32.2
languageName: unknown
linkType: soft
"trough@npm:^2.0.0":
version: 2.1.0
resolution: "trough@npm:2.1.0"
@ -12614,15 +12516,6 @@ __metadata:
languageName: node
linkType: hard
"unist-util-stringify-position@npm:^2.0.0":
version: 2.0.3
resolution: "unist-util-stringify-position@npm:2.0.3"
dependencies:
"@types/unist": ^2.0.2
checksum: f755cadc959f9074fe999578a1a242761296705a7fe87f333a37c00044de74ab4b184b3812989a57d4cd12211f0b14ad397b327c3a594c7af84361b1c25a7f09
languageName: node
linkType: hard
"unist-util-stringify-position@npm:^3.0.0":
version: 3.0.3
resolution: "unist-util-stringify-position@npm:3.0.3"