Compare commits
10 commits
6627b31107
...
7c4365d969
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c4365d969 | ||
|
|
4c353f4568 | ||
|
|
91bb998aa3 | ||
|
|
f0f878a122 | ||
|
|
0f735d2efb | ||
|
|
2dd167e898 | ||
|
|
00d7fb79c3 | ||
|
|
465c01be44 | ||
|
|
83300a4701 | ||
|
|
efe8868c1d |
36
Dockerfile
36
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
252
README.md
252
README.md
|
|
@ -1,174 +1,132 @@
|
|||
# Remix Indie Stack
|
||||
# translAIte
|
||||
|
||||

|
||||
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
|
||||
|
||||
[](https://gitpod.io/#https://github.com/remix-run/indie-stack/tree/main)
|
||||
|
||||
## Development
|
||||
|
||||
- Initial setup:
|
||||
|
||||
```sh
|
||||
npm run setup
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -v /path/to/data:/data/data.db nzambello/translaite
|
||||
```
|
||||
|
||||
- Start dev server:
|
||||
If you want to use different defaults, you can build your own image. See [Running with docker](#running-with-docker)
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
### 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
|
||||
```
|
||||
|
||||
This starts your app in development mode, rebuilding assets on file changes.
|
||||
Example of docker-compose.yml with [Traefik](https://traefik.io/) as reverse proxy:
|
||||
|
||||
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
|
||||
```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"
|
||||
```
|
||||
|
||||
> **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.
|
||||
## Custom deployment or development
|
||||
|
||||
- Create two apps on Fly, one for staging and one for production:
|
||||
### Tech Stack
|
||||
|
||||
```sh
|
||||
fly apps create translaite-2ae4
|
||||
fly apps create translaite-2ae4-staging
|
||||
- [Remix](https://remix.run)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [SQLite](https://sqlite.org)
|
||||
- [Tailwind](https://tailwindcss.com)
|
||||
- [Docker](https://docker.com)
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/nzambello/translaite.git
|
||||
cd translaite
|
||||
|
||||
# Install dependencies
|
||||
yarn install
|
||||
|
||||
# Setup .env
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
|
||||
# Start the app
|
||||
yarn dev
|
||||
```
|
||||
|
||||
> **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy.
|
||||
### Running with Docker
|
||||
|
||||
- Initialize Git.
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/nzambello/translaite.git
|
||||
cd translaite
|
||||
|
||||
```sh
|
||||
git init
|
||||
# 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
|
||||
```
|
||||
|
||||
- Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
|
||||
### Multi-platform Docker image
|
||||
|
||||
```sh
|
||||
git remote add origin <ORIGIN_URL>
|
||||
```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 .
|
||||
```
|
||||
|
||||
- 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`.
|
||||
## License
|
||||
|
||||
- Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:
|
||||
[Nicola Zambello](https://github.com/nzambello) © 2023
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
afterEach(() => {
|
||||
cy.cleanupUser();
|
||||
});
|
||||
```
|
||||
|
||||
That way, we can keep your local db clean and keep your tests isolated from one another.
|
||||
|
||||
### Vitest
|
||||
|
||||
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).
|
||||
|
||||
### Type Checking
|
||||
|
||||
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`.
|
||||
|
||||
### Linting
|
||||
|
||||
This project uses ESLint for linting. That is configured in `.eslintrc.js`.
|
||||
|
||||
### Formatting
|
||||
|
||||
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.
|
||||
[GNU GPLv3](https://github.com/nzambello/translaite/raw/main/LICENSE)
|
||||
|
|
|
|||
12
app/config.server.ts
Normal file
12
app/config.server.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
52
app/models/translation.server.ts
Normal file
52
app/models/translation.server.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
125
app/routes/account.tsx
Normal file
125
app/routes/account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,6 +175,7 @@ export default function Join() {
|
|||
>
|
||||
Create Account
|
||||
</button>
|
||||
{!loaderData.isFirstUser && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
|
|
@ -159,6 +190,7 @@ export default function Join() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,7 +169,8 @@ export default function LoginPage() {
|
|||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
{!!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"
|
||||
|
|
@ -167,6 +182,7 @@ export default function LoginPage() {
|
|||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
13
app/routes/t._index.tsx
Normal 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
250
app/routes/t.new.tsx
Normal 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
134
app/routes/t.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "openAIKey" TEXT;
|
||||
|
|
@ -15,7 +15,9 @@ model User {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
password Password?
|
||||
notes Note[]
|
||||
translations Translation[]
|
||||
|
||||
openAIKey String?
|
||||
}
|
||||
|
||||
model Password {
|
||||
|
|
@ -25,10 +27,11 @@ model Password {
|
|||
userId String @unique
|
||||
}
|
||||
|
||||
model Note {
|
||||
model Translation {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
body String
|
||||
lang String
|
||||
text String
|
||||
result String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
18
start.sh
18
start.sh
|
|
@ -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
257
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue