diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..227a419 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +!.env.example +.DS_Store +.react-router +build +node_modules +uploads +*.db +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..207bf93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/Dockerfile.bun b/Dockerfile.bun new file mode 100644 index 0000000..973038e --- /dev/null +++ b/Dockerfile.bun @@ -0,0 +1,25 @@ +FROM oven/bun:1 AS dependencies-env +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --production + +FROM dependencies-env AS build-env +COPY ./package.json bun.lockb /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN bun run build + +FROM dependencies-env +COPY ./package.json bun.lockb /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/Dockerfile.pnpm b/Dockerfile.pnpm new file mode 100644 index 0000000..57916af --- /dev/null +++ b/Dockerfile.pnpm @@ -0,0 +1,26 @@ +FROM node:20-alpine AS dependencies-env +RUN npm i -g pnpm +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --prod --frozen-lockfile + +FROM dependencies-env AS build-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN pnpm build + +FROM dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..202341f --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Welcome to React Router! + +A modern, production-ready template for building full-stack React applications using React Router. + +## Features + +- 🚀 Server-side rendering +- ⚡️ Hot Module Replacement (HMR) +- 📦 Asset bundling and optimization +- 🔄 Data loading and mutations +- 🔒 TypeScript by default +- 🎉 TailwindCSS for styling +- 📖 [React Router docs](https://reactrouter.com/) + +## Getting Started + +### Installation + +Install the dependencies: + +```bash +npm install +``` + +### Development + +Start the development server with HMR: + +```bash +npm run dev +``` + +Your application will be available at `http://localhost:5173`. + +## Building for Production + +Create a production build: + +```bash +npm run build +``` + +## Deployment + +### Docker Deployment + +This template includes three Dockerfiles optimized for different package managers: + +- `Dockerfile` - for npm +- `Dockerfile.pnpm` - for pnpm +- `Dockerfile.bun` - for bun + +To build and run using Docker: + +```bash +# For npm +docker build -t my-app . + +# For pnpm +docker build -f Dockerfile.pnpm -t my-app . + +# For bun +docker build -f Dockerfile.bun -t my-app . + +# Run the container +docker run -p 3000:3000 my-app +``` + +The containerized application can be deployed to any platform that supports Docker, including: + +- AWS ECS +- Google Cloud Run +- Azure Container Apps +- Digital Ocean App Platform +- Fly.io +- Railway + +### DIY Deployment + +If you're familiar with deploying Node applications, the built-in app server is production-ready. + +Make sure to deploy the output of `npm run build` + +``` +├── package.json +├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. + +--- + +Built with ❤️ using React Router. diff --git a/app/.server/auth.ts b/app/.server/auth.ts new file mode 100644 index 0000000..2125440 --- /dev/null +++ b/app/.server/auth.ts @@ -0,0 +1,127 @@ +import bcrypt from "bcrypt"; +import crypto from "node:crypto"; +import { createCookieSessionStorage, redirect } from "react-router"; + +import { env } from "./env"; +import { + createUser, + createVerificationToken, + deleteVerificationToken, + getCredentialAccount, + getUser, + getUserByEmail, + getVerificationToken, + updatePassword, +} from "./data/user"; + +const authSessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + secrets: [env.SESSION_SECRET], + sameSite: "lax", + path: "/", + }, +}); + +class Auth { + async #getSession(request?: Request) { + const session = await authSessionStorage.getSession( + request?.headers.get("Cookie") + ); + return session; + } + + async getUserId(request: Request) { + const session = await this.#getSession(request); + return session.get("userId"); + } + + async getUser(request: Request) { + const userId = await this.getUserId(request); + if (!userId) return null; + const user = await getUser(userId); + return user; + } + + async getUserOrFail(request: Request) { + const user = await this.getUser(request); + const url = new URL(request.url); + const searchParams = + url.pathname && + new URLSearchParams([["redirect", url.pathname + url.search]]); + if (!user) throw redirect(`/login?${searchParams}`); + return user; + } + + async login(userId: number) { + const session = await this.#getSession(); + session.set("userId", userId); + const cookie = await authSessionStorage.commitSession(session); + return cookie; + } + + async signIn(email: string, password: string) { + const account = await getCredentialAccount(email); + if (!account || !account.password) { + throw new Error("Invalid email or password"); + } + + const isValid = await bcrypt.compare(password, account.password); + if (!isValid) { + throw new Error("Invalid email or password"); + } + + const cookie = await this.login(account.userId); + return cookie; + } + + async signUp(name: string, email: string, password: string) { + password = await bcrypt.hash(password, 10); + const user = await createUser(name, email, password); + const cookie = await this.login(user.id); + return cookie; + } + + async logout() { + const session = await this.#getSession(); + const cookie = await authSessionStorage.destroySession(session); + return cookie; + } + + async forgetPassword(email: string) { + const user = await getUserByEmail(email); + if (!user) throw new Error("User not found"); + + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); + const token = crypto.randomBytes(32).toString("hex"); + + await createVerificationToken(email, expiresAt.toISOString(), token); + return { user, token }; + } + + async resetPassword(email: string, password: string, token: string) { + const verificationToken = await getVerificationToken(email); + + if ( + !verificationToken || + new Date(verificationToken.expires) < new Date() + ) { + throw new Error("Invalid or expired token"); + } + + const isValid = await bcrypt.compare(token, verificationToken.token); + if (!isValid) { + throw new Error("Invalid token"); + } + + password = await bcrypt.hash(password, 10); + + await updatePassword(verificationToken.identifier, password); + await deleteVerificationToken(verificationToken.token); + } +} + +export const auth = new Auth(); diff --git a/app/.server/body-parser.ts b/app/.server/body-parser.ts new file mode 100644 index 0000000..4d8e6dd --- /dev/null +++ b/app/.server/body-parser.ts @@ -0,0 +1,42 @@ +class BodyParser { + async parse(request: Request) { + const searchParams = new URL(request.url).searchParams; + const formData = await request.formData(); + return { ...this.parseForm(searchParams), ...this.parseForm(formData) }; + } + + parseForm(form: FormData | URLSearchParams) { + const object: Record = {}; + form.forEach((value, key) => { + const parts = key.split(/[.[\]]+/).filter(Boolean); + let current = object; + + parts.forEach((part, index) => { + const isLast = index === parts.length - 1; + const nextPart = parts[index + 1]; + const isNextArray = !isLast && !isNaN(Number(nextPart)); + + if (isLast) { + current[part] = value; + } else { + if (isNextArray) { + if (!Array.isArray(current[part])) { + current[part] = []; + } + current = current[part]; + } else { + if ( + !(typeof current[part] === "object" && current[part] !== null) + ) { + current[part] = {}; + } + current = current[part]; + } + } + }); + }); + return object; + } +} + +export const bodyParser = new BodyParser(); diff --git a/app/.server/data/comment.ts b/app/.server/data/comment.ts new file mode 100644 index 0000000..ad1d217 --- /dev/null +++ b/app/.server/data/comment.ts @@ -0,0 +1,90 @@ +import { and, asc, countDistinct, eq, getTableColumns, sql } from "drizzle-orm"; + +import { db, schema } from "../db"; + +export const getComments = async (discussionId: number, userId = 0) => { + const comments = await db + .select({ + ...getTableColumns(schema.comments), + author: { + name: schema.users.name, + image: schema.users.image, + }, + votesCount: countDistinct(schema.commentVotes.userId), + voted: + sql`COUNT(CASE WHEN ${schema.commentVotes.userId} = ${userId} THEN 1 END)`.mapWith( + Boolean + ), + isCommentAuthor: sql`${schema.comments.authorId} = ${userId}`.mapWith( + Boolean + ), + isDiscussionAuthor: + sql`${schema.comments.authorId} = ${schema.discussions.authorId}`.mapWith( + Boolean + ), + }) + .from(schema.comments) + .leftJoin(schema.users, eq(schema.comments.authorId, schema.users.id)) + .leftJoin(schema.discussions, eq(schema.discussions.id, discussionId)) + .leftJoin( + schema.commentVotes, + eq(schema.commentVotes.commentId, schema.comments.id) + ) + .where(eq(schema.comments.discussionId, discussionId)) + .groupBy(schema.comments.id) + .orderBy(asc(schema.comments.createdAt)); + return comments; +}; +export type CommentsDto = Awaited>; + +export const createComment = async ( + discussionId: number, + body: string, + userId: number +) => { + const [comment] = await db + .insert(schema.comments) + .values({ + body, + authorId: userId, + discussionId, + }) + .returning(); + return comment; +}; + +export const updateComment = async ( + id: number, + body: string, + userId: number +) => { + await db + .update(schema.comments) + .set({ body }) + .where( + and(eq(schema.comments.authorId, userId), eq(schema.comments.id, id)) + ); +}; + +export const deleteComment = async (id: number, userId: number) => { + await db + .delete(schema.comments) + .where( + and(eq(schema.comments.authorId, userId), eq(schema.comments.id, id)) + ); +}; + +export const voteComment = async (id: number, userId: number) => { + await db.insert(schema.commentVotes).values({ userId, commentId: id }); +}; + +export const unvoteComment = async (id: number, userId: number) => { + await db + .delete(schema.commentVotes) + .where( + and( + eq(schema.commentVotes.commentId, id), + eq(schema.commentVotes.userId, userId) + ) + ); +}; diff --git a/app/.server/data/discussion.ts b/app/.server/data/discussion.ts new file mode 100644 index 0000000..b1c773d --- /dev/null +++ b/app/.server/data/discussion.ts @@ -0,0 +1,177 @@ +import { + and, + count, + countDistinct, + desc, + eq, + like, + or, + sql, +} from "drizzle-orm"; + +import { db, schema } from "~/.server/db"; + +export const createDiscussion = async ( + title: string, + body: string, + userId: number +) => { + const [discussion] = await db + .insert(schema.discussions) + .values({ + title, + body, + authorId: userId, + }) + .returning(); + return discussion; +}; + +export const getDiscussions = async ( + filters: { page: number; limit: number; q?: string }, + userId = 0 +) => { + const { page, limit, q } = filters; + const offset = (page - 1) * limit; + + const sqlFilters = q + ? or( + like(schema.discussions.title, `%${q}%`), + like(schema.discussions.body, `%${q}%`) + ) + : undefined; + + const [rawTotal, rawDiscussions] = await Promise.all([ + db + .select({ total: count(schema.discussions.id) }) + .from(schema.discussions) + .where(sqlFilters), + db + .select({ + id: schema.discussions.id, + title: schema.discussions.title, + createdAt: schema.discussions.createdAt, + author: { + name: schema.users.name, + image: schema.users.image, + }, + commentsCount: countDistinct(schema.comments.id), + votesCount: countDistinct(schema.discussionVotes.userId), + voted: + sql`COUNT(CASE WHEN ${schema.discussionVotes.userId} = ${userId} THEN 1 END)`.mapWith( + Boolean + ), + }) + .from(schema.discussions) + .leftJoin(schema.users, eq(schema.discussions.authorId, schema.users.id)) + .leftJoin( + schema.comments, + eq(schema.comments.discussionId, schema.discussions.id) + ) + .leftJoin( + schema.discussionVotes, + eq(schema.discussionVotes.discussionId, schema.discussions.id) + ) + .where(sqlFilters) + .groupBy(schema.discussions.id) + .orderBy( + desc(schema.comments.createdAt), + desc(schema.discussions.createdAt) + ) + .offset(offset) + .limit(limit), + ]); + + return { + discussions: rawDiscussions, + total: rawTotal[0].total, + limit, + }; +}; +export type DiscussionsDto = Awaited>; + +export const getDiscussion = async (id: number, userId = 0) => { + const [discussion] = await db + .select({ + id: schema.discussions.id, + title: schema.discussions.title, + body: schema.discussions.body, + createdAt: schema.discussions.createdAt, + author: { + name: schema.users.name, + image: schema.users.image, + }, + commentsCount: countDistinct(schema.comments.id), + votesCount: countDistinct(schema.discussionVotes.userId), + voted: + sql`COUNT(CASE WHEN ${schema.discussionVotes.userId} = ${userId} THEN 1 END)`.mapWith( + Boolean + ), + }) + .from(schema.discussions) + .leftJoin(schema.users, eq(schema.discussions.authorId, schema.users.id)) + .leftJoin( + schema.comments, + eq(schema.comments.discussionId, schema.discussions.id) + ) + .leftJoin( + schema.discussionVotes, + eq(schema.discussionVotes.discussionId, schema.discussions.id) + ) + .where(eq(schema.discussions.id, id)) + .limit(1); + return discussion; +}; +export type DiscussionDto = Awaited>; + +export const getDiscussionWithReply = async (id: number) => { + const [discussions, comments] = await Promise.all([ + db + .select({ + id: schema.discussions.id, + title: schema.discussions.title, + body: sql`${schema.discussions.body}`.mapWith(formatLargeText), + }) + .from(schema.discussions) + .where(eq(schema.discussions.id, id)) + .limit(1), + db + .select({ + body: sql`${schema.comments.body}`.mapWith(formatLargeText), + author: { + name: schema.users.name, + image: schema.users.image, + }, + }) + .from(schema.comments) + .leftJoin(schema.users, eq(schema.comments.authorId, schema.users.id)) + .where(eq(schema.comments.discussionId, id)) + .orderBy(desc(schema.comments.createdAt)) + .limit(1), + ]); + const discussion = discussions.at(0); + const reply = comments.at(0); + + if (!discussion) throw new Error("Discussion not found"); + + return { ...discussion, reply }; +}; + +export const voteDiscussion = async (id: number, userId: number) => { + await db.insert(schema.discussionVotes).values({ userId, discussionId: id }); +}; + +export const unvoteDiscussion = async (id: number, userId: number) => { + await db + .delete(schema.discussionVotes) + .where( + and( + eq(schema.discussionVotes.discussionId, id), + eq(schema.discussionVotes.userId, userId) + ) + ); +}; + +function formatLargeText(text: string) { + return text.length > 100 ? text.slice(0, 100) + "..." : text; +} diff --git a/app/.server/data/user.ts b/app/.server/data/user.ts new file mode 100644 index 0000000..11c276a --- /dev/null +++ b/app/.server/data/user.ts @@ -0,0 +1,113 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { db, schema } from "../db"; + +export const createUser = async ( + name: string, + email: string, + password: string +) => { + const user = await db.transaction(async (tx) => { + const [user] = await tx + .insert(schema.users) + .values({ email, name }) + .returning(); + await tx + .insert(schema.accounts) + .values({ type: "credential", password, userId: user.id }); + return user; + }); + return user; +}; + +export const getUser = async (userId: number) => { + const users = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + const user = users.at(0); + if (!user) return null; + return user; +}; + +export const getUserByEmail = async (email: string) => { + const users = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + return users.at(0) ?? null; +}; + +export const updateUser = async ( + userId: number, + name: string, + image?: string +) => { + await db + .update(schema.users) + .set({ name, image }) + .where(eq(schema.users.id, userId)); +}; + +export const createVerificationToken = async ( + email: string, + expires: string, + token: string +) => { + await db.insert(schema.verificationTokens).values({ + identifier: email, + expires: expires, + token: token, + }); +}; + +export const getVerificationToken = async (email: string) => { + const verifications = await db + .select() + .from(schema.verificationTokens) + .where(eq(schema.verificationTokens.identifier, email)) + .limit(1); + return verifications.at(0) ?? null; +}; + +export const deleteVerificationToken = async (token: string) => { + await db + .delete(schema.verificationTokens) + .where(eq(schema.verificationTokens.token, token)); +}; + +export const getCredentialAccount = async (email: string) => { + const accounts = await db + .select({ + password: schema.accounts.password, + userId: schema.accounts.userId, + }) + .from(schema.accounts) + .where( + and( + eq(schema.accounts.type, "credential"), + eq( + schema.accounts.userId, + sql`(select id from ${schema.users} where email = ${email})` + ) + ) + ); + return accounts.at(0) ?? null; +}; + +export const updatePassword = async (email: string, password: string) => { + await db + .update(schema.accounts) + .set({ password }) + .where( + and( + eq(schema.accounts.type, "credential"), + eq( + schema.accounts.userId, + sql`(SELECT id FROM ${schema.users} WHERE email = ${email})` + ) + ) + ); +}; diff --git a/app/.server/db.ts b/app/.server/db.ts new file mode 100644 index 0000000..28b615d --- /dev/null +++ b/app/.server/db.ts @@ -0,0 +1,8 @@ +import { drizzle } from "drizzle-orm/better-sqlite3"; + +import { env } from "./env"; +import * as schema from "../../drizzle/schema"; + +const db = drizzle(env.DATABASE_URL, { schema }); + +export { db, schema }; diff --git a/app/.server/env.ts b/app/.server/env.ts new file mode 100644 index 0000000..953629e --- /dev/null +++ b/app/.server/env.ts @@ -0,0 +1,15 @@ +import vine from "@vinejs/vine"; + +export const env = await vine.validate({ + data: process.env, + schema: vine.object({ + NODE_ENV: vine.string(), + DATABASE_URL: vine.string(), + SMTP_HOST: vine.string(), + SMTP_PORT: vine.number(), + SMTP_USER: vine.string(), + SMTP_PASS: vine.string(), + SITE_URL: vine.string(), + SESSION_SECRET: vine.string(), + }), +}); diff --git a/app/.server/mailer.ts b/app/.server/mailer.ts new file mode 100644 index 0000000..530ab1e --- /dev/null +++ b/app/.server/mailer.ts @@ -0,0 +1,41 @@ +import nodemailer from "nodemailer"; +import { render } from "@react-email/components"; + +import { env } from "./env"; + +interface MailMessage { + from: string; + to: string; + subject: string; + body: React.ReactElement; +} + +class Mailer { + private transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.NODE_ENV === "production", + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASS, + }, + }); + async send({ to, from, subject, body }: MailMessage) { + const [html, text] = await Promise.all([ + render(body), + render(body, { plainText: true }), + ]); + await new Promise((resolve, reject) => { + this.transporter.sendMail( + { to, from, subject, html, text }, + (err, info) => { + if (err) reject(err); + console.log(nodemailer.getTestMessageUrl(info)); + resolve(info); + } + ); + }); + } +} + +export const mailer = new Mailer(); diff --git a/app/.server/response.ts b/app/.server/response.ts new file mode 100644 index 0000000..dee327e --- /dev/null +++ b/app/.server/response.ts @@ -0,0 +1,58 @@ +import type { UNSAFE_DataWithResponseInit } from "react-router"; + +import { data } from "react-router"; +import { errors } from "@vinejs/vine"; + +type HandleErrorReturn = UNSAFE_DataWithResponseInit< + { + status: "error"; + error: Error; + data?: never; + } & T +>; + +export function handleError( + error: unknown, + additionalFields?: T +): HandleErrorReturn { + if (error instanceof Response) throw error; + + const fields = (additionalFields ?? {}) as T; + + if (error instanceof errors.E_VALIDATION_ERROR) { + return data( + { + ...fields, + status: "error", + error: new Error(error.messages?.[0]?.message ?? error.message), + }, + { status: 422 } + ); + } + + return data( + { + ...fields, + status: "error", + error: new Error( + error instanceof Error ? error.message : "Unknown Server Error" + ), + }, + { status: 500 } + ); +} + +type HandleSuccessReturn = { + status: "success"; + data?: T; + error?: never; +}; +/** + * Error boundaries are automatically shown when route loaders throw, so + * this is only needed for API routes to distinguish success/error responses + * @param data + * @returns + */ +export function handleSuccess(data?: T): HandleSuccessReturn { + return { status: "success", data }; +} diff --git a/app/.server/storage.ts b/app/.server/storage.ts new file mode 100644 index 0000000..8c5c64c --- /dev/null +++ b/app/.server/storage.ts @@ -0,0 +1,3 @@ +import { LocalFileStorage } from "@mjackson/file-storage/local"; + +export const storage = new LocalFileStorage("./uploads"); diff --git a/app/.server/toasts.ts b/app/.server/toasts.ts new file mode 100644 index 0000000..e77d1df --- /dev/null +++ b/app/.server/toasts.ts @@ -0,0 +1,33 @@ +import { createCookieSessionStorage } from "react-router"; + +import { env } from "./env"; + +const toastSessionStorage = createCookieSessionStorage({ + cookie: { + name: "__toast", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + secrets: [env.SESSION_SECRET], + sameSite: "lax", + path: "/", + }, +}); + +class ToastStorage { + async put(message: string) { + const session = await toastSessionStorage.getSession(); + session.flash("message", message); + const cookie = await toastSessionStorage.commitSession(session); + return cookie; + } + async pop(request: Request) { + const session = await toastSessionStorage.getSession( + request.headers.get("cookie") + ); + const message = session.get("message"); + const cookie = await toastSessionStorage.commitSession(session); + return { message, cookie }; + } +} + +export const toasts = new ToastStorage(); diff --git a/app/.server/validators/comment.ts b/app/.server/validators/comment.ts new file mode 100644 index 0000000..c0f071f --- /dev/null +++ b/app/.server/validators/comment.ts @@ -0,0 +1,18 @@ +import vine from "@vinejs/vine"; + +export const createCommentValidator = vine.compile( + vine.object({ + body: vine.string().trim().minLength(1), + discussionId: vine.number(), + }) +); + +export const updateCommentValidator = vine.compile( + vine.object({ body: vine.string().trim().minLength(1) }) +); + +export const voteCommentValidator = vine.compile( + vine.object({ + voted: vine.boolean(), + }) +); diff --git a/app/.server/validators/discussion.ts b/app/.server/validators/discussion.ts new file mode 100644 index 0000000..3917482 --- /dev/null +++ b/app/.server/validators/discussion.ts @@ -0,0 +1,22 @@ +import vine from "@vinejs/vine"; + +export const getDiscussionsValidator = vine.compile( + vine.object({ + page: vine.number().optional(), + limit: vine.number().optional(), + q: vine.string().optional(), + }) +); + +export const createDiscussionValidator = vine.compile( + vine.object({ + title: vine.string().trim().minLength(1), + body: vine.string().trim().minLength(1), + }) +); + +export const voteDiscussionValidator = vine.compile( + vine.object({ + voted: vine.boolean(), + }) +); diff --git a/app/.server/validators/user.ts b/app/.server/validators/user.ts new file mode 100644 index 0000000..e215ba1 --- /dev/null +++ b/app/.server/validators/user.ts @@ -0,0 +1,72 @@ +import vine from "@vinejs/vine"; + +import { env } from "../env"; + +export const signUpValidator = vine.compile( + vine.object({ + name: vine.string().trim(), + email: vine.string().email(), + password: vine.string().minLength(6).confirmed(), + }) +); + +export const signInValidator = vine.compile( + vine.object({ + email: vine.string().email(), + password: vine.string().minLength(8), + redirect: vine + .string() + .optional() + .transform((value) => { + try { + const url = new URL(value, env.SITE_URL); + return url.pathname + url.search; + } catch { + return; + } + }), + }) +); + +export const forgetPasswordValidator = vine.compile( + vine.object({ + email: vine.string().email(), + }) +); + +export const resetPasswordValidator = vine.compile( + vine.object({ + email: vine.string().email(), + password: vine.string().minLength(8).confirmed(), + token: vine.string(), + }) +); + +export const updateUserValidator = vine.compile( + vine.object({ + name: vine.string(), + image: vine.any().use( + vine.createRule((value, _, field) => { + if (!(value instanceof File)) { + field.report("The {{ field }} must be a file", "file", field); + return; + } + + // handle empty file + if (!value.name) { + field.mutate(void 0, field); + return; + } + + // limits to 5 MB + if (value.size > 5 * 1024 * 1024) { + field.report( + "The {{ field }} is greater than max size", + "file", + field + ); + } + })() + ), + }) +); diff --git a/app/api/comments.$id.delete.tsx b/app/api/comments.$id.delete.tsx new file mode 100644 index 0000000..ef9fec2 --- /dev/null +++ b/app/api/comments.$id.delete.tsx @@ -0,0 +1,28 @@ +import { useFetcher } from "react-router"; + +import { auth } from "~/.server/auth"; +import { deleteComment } from "~/.server/data/comment"; +import { handleError, handleSuccess } from "~/.server/response"; + +import type { Route } from "./+types/comments.$id.delete"; + +export const action = async ({ request, params }: Route.ActionArgs) => { + try { + const user = await auth.getUserOrFail(request); + await deleteComment(Number(params.id), user.id); + return handleSuccess(); + } catch (error) { + return handleError(error); + } +}; + +export function useDeleteCommentFetcher(id: number) { + const fetcher = useFetcher(); + return { + ...fetcher, + formProps: { + method: "POST", + action: `/api/comments/${id}/delete`, + } as const, + }; +} diff --git a/app/api/comments.$id.edit.tsx b/app/api/comments.$id.edit.tsx new file mode 100644 index 0000000..bfb79b0 --- /dev/null +++ b/app/api/comments.$id.edit.tsx @@ -0,0 +1,31 @@ +import { useFetcher } from "react-router"; + +import { auth } from "~/.server/auth"; +import { bodyParser } from "~/.server/body-parser"; +import { updateComment } from "~/.server/data/comment"; +import { handleError, handleSuccess } from "~/.server/response"; +import { updateCommentValidator } from "~/.server/validators/comment"; + +import type { Route } from "./+types/discussions.$id.vote"; + +export const action = async ({ request, params }: Route.ActionArgs) => { + try { + const user = await auth.getUserOrFail(request); + const form = await bodyParser.parse(request); + const { body } = await updateCommentValidator.validate(form); + + await updateComment(Number(params.id), body, user.id); + + return handleSuccess(); + } catch (error) { + return handleError(error); + } +}; + +export function useEditCommentFetcher(id: number) { + const fetcher = useFetcher(); + return { + ...fetcher, + formProps: { method: "POST", action: `/api/comments/${id}/edit` } as const, + }; +} diff --git a/app/api/comments.$id.vote.tsx b/app/api/comments.$id.vote.tsx new file mode 100644 index 0000000..d84bfff --- /dev/null +++ b/app/api/comments.$id.vote.tsx @@ -0,0 +1,39 @@ +import { useFetcher } from "react-router"; + +import { auth } from "~/.server/auth"; +import { bodyParser } from "~/.server/body-parser"; +import { handleError, handleSuccess } from "~/.server/response"; +import { unvoteComment, voteComment } from "~/.server/data/comment"; +import { voteCommentValidator } from "~/.server/validators/comment"; + +import type { Route } from "./+types/discussions.$id.vote"; + +export const action = async ({ request, params }: Route.ActionArgs) => { + try { + const user = await auth.getUserOrFail(request); + const body = await bodyParser.parse(request); + const { voted } = await voteCommentValidator.validate(body); + + if (voted) { + await voteComment(Number(params.id), user.id); + } else { + await unvoteComment(Number(params.id), user.id); + } + return handleSuccess(); + } catch (error) { + return handleError(error); + } +}; + +export function useVoteCommentFetcher(id: number) { + const fetcher = useFetcher(); + return { + voted: fetcher.formData && fetcher.formData.get("voted") === "voted", + submit(voted: boolean) { + fetcher.submit( + { voted }, + { action: `/api/comments/${id}/vote`, method: "POST" } + ); + }, + }; +} diff --git a/app/api/comments.new.tsx b/app/api/comments.new.tsx new file mode 100644 index 0000000..3d6a9dc --- /dev/null +++ b/app/api/comments.new.tsx @@ -0,0 +1,32 @@ +import { useFetcher } from "react-router"; + +import { auth } from "~/.server/auth"; +import { bodyParser } from "~/.server/body-parser"; +import { createComment } from "~/.server/data/comment"; +import { handleError, handleSuccess } from "~/.server/response"; +import { createCommentValidator } from "~/.server/validators/comment"; + +import type { Route } from "./+types/comments.new"; + +export const action = async ({ request }: Route.ActionArgs) => { + const form = await bodyParser.parse(request); + try { + const user = await auth.getUserOrFail(request); + const { body, discussionId } = await createCommentValidator.validate(form); + const { id } = await createComment(discussionId, body, user.id); + return handleSuccess({ id }); + } catch (error) { + return handleError(error); + } +}; + +export const useCreateCommentFetcher = () => { + const fetcher = useFetcher(); + const { data, error } = fetcher.data ?? {}; + return { + ...fetcher, + data, + error, + formProps: { method: "POST", action: "/api/comments/new" } as const, + }; +}; diff --git a/app/api/discussions.$id.hovercard.tsx b/app/api/discussions.$id.hovercard.tsx new file mode 100644 index 0000000..a9f9460 --- /dev/null +++ b/app/api/discussions.$id.hovercard.tsx @@ -0,0 +1,32 @@ +import { useFetcher } from "react-router"; + +import { handleError, handleSuccess } from "~/.server/response"; +import { getDiscussionWithReply } from "~/.server/data/discussion"; + +import type { Route } from "./+types/discussions.$id.hovercard"; + +export const loader = async ({ params }: Route.LoaderArgs) => { + try { + const id = Number(params.id); + const discussion = await getDiscussionWithReply(id); + return handleSuccess(discussion); + } catch (error) { + return handleError(error); + } +}; + +export const shouldRevalidate = () => false; + +export function useHoverDiscussionFetcher(id: string | number) { + const fetcher = useFetcher(); + const { data, error } = fetcher.data ?? {}; + return { + data, + error, + load: () => { + if (!data && fetcher.state === "idle") { + fetcher.load(`/api/discussions/${id}/hovercard`); + } + }, + }; +} diff --git a/app/api/discussions.$id.vote.tsx b/app/api/discussions.$id.vote.tsx new file mode 100644 index 0000000..3b00e2a --- /dev/null +++ b/app/api/discussions.$id.vote.tsx @@ -0,0 +1,39 @@ +import { useFetcher } from "react-router"; + +import { auth } from "~/.server/auth"; +import { bodyParser } from "~/.server/body-parser"; +import { handleError, handleSuccess } from "~/.server/response"; +import { voteDiscussionValidator } from "~/.server/validators/discussion"; +import { unvoteDiscussion, voteDiscussion } from "~/.server/data/discussion"; + +import type { Route } from "./+types/discussions.$id.vote"; + +export const action = async ({ request, params }: Route.ActionArgs) => { + try { + const user = await auth.getUserOrFail(request); + const body = await bodyParser.parse(request); + const { voted } = await voteDiscussionValidator.validate(body); + + if (voted) { + await voteDiscussion(Number(params.id), user.id); + } else { + await unvoteDiscussion(Number(params.id), user.id); + } + return handleSuccess(); + } catch (error) { + return handleError(error); + } +}; + +export function useVoteDiscussionFetcher(id: number) { + const fetcher = useFetcher(); + return { + voted: fetcher.formData && fetcher.formData.get("voted") === "voted", + submit(voted: boolean) { + fetcher.submit( + { voted }, + { action: `/api/discussions/${id}/vote`, method: "POST" } + ); + }, + }; +} diff --git a/app/api/uploads.$.tsx b/app/api/uploads.$.tsx new file mode 100644 index 0000000..891b768 --- /dev/null +++ b/app/api/uploads.$.tsx @@ -0,0 +1,19 @@ +import { storage } from "~/.server/storage"; + +import type { Route } from "./+types/uploads.$"; + +export async function loader({ params }: Route.LoaderArgs) { + const key = params["*"]; + const file = await storage.get(key); + + if (!file) { + throw new Response("File not found", { status: 404 }); + } + + return new Response(file.stream(), { + headers: { + "Content-Type": file.type, + "Content-Disposition": `attachment; filename=${file.name}`, + }, + }); +} diff --git a/app/root.css b/app/root.css new file mode 100644 index 0000000..531b1dc --- /dev/null +++ b/app/root.css @@ -0,0 +1,20 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply bg-background; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} + +:root { + --color-background: 0 0% 100%; + --color-foreground: 222.2 47.4% 11.2%; + + --color-muted: 210 40% 96.1%; + --color-muted-foreground: 215.4 16.3% 46.9%; +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..ccd2ec9 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,109 @@ +import { useEffect } from "react"; +import { Toaster, toast } from "sonner"; +import { + data, + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useFetchers, + useLoaderData, +} from "react-router"; + +import type { Route } from "./+types/root"; + +import stylesheet from "./root.css?url"; +import { toasts } from "./.server/toasts"; +import { NavigationProgress } from "./ui/shared/navigation-progress"; + +export const meta: Route.MetaFunction = () => [{ title: "Discussions" }]; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, + { rel: "stylesheet", href: stylesheet }, +]; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const { message, cookie } = await toasts.pop(request); + return data({ message }, { headers: [["set-cookie", cookie]] }); +}; + +export default function App() { + return ( + + + + + + + + + + + + + + + + + ); +} + +function GlobalToasts() { + const loaderData = useLoaderData(); + const fetchers = useFetchers(); + const messages = fetchers + .map((it) => it.data?.error?.message) + .filter(Boolean); + + useEffect(() => { + if (loaderData.message) + toast.success(loaderData.message, { richColors: true }); + }, [loaderData.message]); + useEffect( + () => + messages.forEach((message) => toast.error(message, { richColors: true })), + [messages] + ); + return null; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 0000000..8cc8a58 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,27 @@ +import { flatRoutes } from "@react-router/fs-routes"; +import { + index, + layout, + prefix, + route, + type RouteConfig, +} from "@react-router/dev/routes"; + +const routes = [ + ...prefix("api", await flatRoutes({ rootDirectory: "api" })), + + route("login", "ui/auth/login.route.tsx"), + route("register", "ui/auth/register.route.tsx"), + route("forgot-password", "ui/auth/forgot-password.route.tsx"), + route("reset-password", "ui/auth/reset-password.route.tsx"), + route("logout", "ui/auth/logout.route.tsx"), + + layout("ui/layouts/main.route.tsx", [ + index("ui/discussions/discussions.route.tsx"), + route("discussions/new", "ui/discussions/new-discussion.route.tsx"), + route("discussions/:id", "ui/discussion/discussion.route.tsx"), + route("profile", "ui/profile/profile.route.tsx"), + ]), +]; + +export default routes satisfies RouteConfig; diff --git a/app/ui/auth/emails/reset-password-email.tsx b/app/ui/auth/emails/reset-password-email.tsx new file mode 100644 index 0000000..3b0d33b --- /dev/null +++ b/app/ui/auth/emails/reset-password-email.tsx @@ -0,0 +1,105 @@ +import { + Body, + Button, + Container, + Head, + Html, + Img, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; + +interface ResetPasswordEmailProps { + userFirstname?: string; + resetPasswordLink?: string; +} + +const baseUrl = process.env.BASE_URL ? `https://${process.env.BASE_URL}` : ""; + +export const ResetPasswordEmail = ({ + userFirstname, + resetPasswordLink, +}: ResetPasswordEmailProps) => { + return ( + + + Discussions, reset your password + + + Discussions +
+ Hi {userFirstname}, + + Someone recently requested a password change for your Discussions + account. If this was you, you can set a new password here: + + + + If you don't want to change your password or didn't + request this, just ignore and delete this message. + + + To keep your account secure, please don't forward this email + to anyone. See our Help Center for{" "} + + more security tips. + + + Happy Discussing! +
+
+ + + ); +}; + +ResetPasswordEmail.PreviewProps = { + userFirstname: "Alan", + resetPasswordLink: "https://discussions.com", +} as ResetPasswordEmailProps; + +const main = { + backgroundColor: "#f6f9fc", + padding: "10px 0", +}; + +const container = { + backgroundColor: "#ffffff", + border: "1px solid #f0f0f0", + padding: "45px", +}; + +const text = { + fontSize: "16px", + fontFamily: + "'Open Sans', 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif", + fontWeight: "300", + color: "#404040", + lineHeight: "26px", +}; + +const button = { + backgroundColor: "#007ee6", + borderRadius: "4px", + color: "#fff", + fontFamily: "'Open Sans', 'Helvetica Neue', Arial", + fontSize: "15px", + textDecoration: "none", + textAlign: "center" as const, + display: "block", + width: "210px", + padding: "14px 7px", +}; + +const anchor = { + textDecoration: "underline", +}; diff --git a/app/ui/auth/forgot-password.route.tsx b/app/ui/auth/forgot-password.route.tsx new file mode 100644 index 0000000..edf577a --- /dev/null +++ b/app/ui/auth/forgot-password.route.tsx @@ -0,0 +1,76 @@ +import { Form, Link, redirect, useNavigation } from "react-router"; + +import { env } from "~/.server/env"; +import { auth } from "~/.server/auth"; +import { mailer } from "~/.server/mailer"; +import { toasts } from "~/.server/toasts"; +import { Button } from "~/ui/shared/button"; +import { handleError } from "~/.server/response"; +import { bodyParser } from "~/.server/body-parser"; +import { forgetPasswordValidator } from "~/.server/validators/user"; + +import type { Route } from "./+types/forgot-password.route"; + +import { Input } from "../shared/input"; +import { ResetPasswordEmail } from "./emails/reset-password-email"; + +export default function Component({ actionData }: Route.ComponentProps) { + const navigation = useNavigation(); + return ( +
+
+

Forgot Password

+
+ {actionData?.error && ( +

+ {actionData.error.message} +

+ )} +
+ + +
+ +
+

+ Remember your password?{" "} + + Login + +

+
+
+ ); +} + +export const action = async ({ request }: Route.ActionArgs) => { + const body = await bodyParser.parse(request); + try { + const { email } = await forgetPasswordValidator.validate(body); + const { user, token } = await auth.forgetPassword(email); + await mailer.send({ + to: email, + from: "me@mail.com", + subject: "Discussions Password Reset", + body: ( + + ), + }); + const cookie = await toasts.put( + "An email was sent to reset your password, check your inbox!" + ); + throw redirect("/login", { headers: [["set-cookie", cookie]] }); + } catch (error) { + return handleError(error, { values: body }); + } +}; diff --git a/app/ui/auth/login.route.tsx b/app/ui/auth/login.route.tsx new file mode 100644 index 0000000..04a8aa7 --- /dev/null +++ b/app/ui/auth/login.route.tsx @@ -0,0 +1,102 @@ +import { Form, Link, redirect, useNavigation } from "react-router"; + +import { auth } from "~/.server/auth"; +import { toasts } from "~/.server/toasts"; +import { Button } from "~/ui/shared/button"; +import { handleError } from "~/.server/response"; +import { bodyParser } from "~/.server/body-parser"; +import { signInValidator } from "~/.server/validators/user"; + +import type { Route } from "./+types/login.route"; + +import { Input } from "../shared/input"; + +export const meta: Route.MetaFunction = () => [{ title: "Login" }]; + +export default function Component({ actionData }: Route.ComponentProps) { + const navigation = useNavigation(); + return ( +
+
+

Login

+
+ {actionData?.error && ( +

+ {actionData.error.message} +

+ )} +
+ + +
+
+
+ + + Forgot Password? + +
+ +
+ +

+ Don't have an account?{" "} + + Register + +

+
+
+
+ ); +} + +export const action = async ({ request }: Route.ActionArgs) => { + const body = await bodyParser.parse(request); + try { + const { + email, + password, + redirect: redirectUrl = "/", + } = await signInValidator.validate(body); + const authCookie = await auth.signIn(email, password); + const toastCookie = await toasts.put("Signed in successfully!"); + throw redirect(redirectUrl, { + headers: [ + ["set-cookie", authCookie], + ["set-cookie", toastCookie], + ], + }); + } catch (error) { + delete body.password; + return handleError(error, { values: body }); + } +}; diff --git a/app/ui/auth/logout.route.tsx b/app/ui/auth/logout.route.tsx new file mode 100644 index 0000000..56b1932 --- /dev/null +++ b/app/ui/auth/logout.route.tsx @@ -0,0 +1,8 @@ +import { redirect } from "react-router"; + +import { auth } from "~/.server/auth"; + +export const action = async () => { + const cookie = await auth.logout(); + throw redirect("/login", { headers: [["set-cookie", cookie]] }); +}; diff --git a/app/ui/auth/register.route.tsx b/app/ui/auth/register.route.tsx new file mode 100644 index 0000000..ff0c8db --- /dev/null +++ b/app/ui/auth/register.route.tsx @@ -0,0 +1,99 @@ +import { Form, Link, redirect, useNavigation } from "react-router"; + +import { auth } from "~/.server/auth"; +import { Button } from "~/ui/shared/button"; +import { handleError } from "~/.server/response"; +import { bodyParser } from "~/.server/body-parser"; +import { signUpValidator } from "~/.server/validators/user"; + +import type { Route } from "./+types/register.route"; + +import { Input } from "../shared/input"; + +export default function Component({ actionData }: Route.ComponentProps) { + const navigation = useNavigation(); + return ( +
+
+

Register

+
+ {actionData?.error && ( +

+ {actionData.error.message} +

+ )} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ Already have an account?{" "} + + Sign in now + +

+
+
+ ); +} + +export const action = async ({ request }: Route.ActionArgs) => { + const body = await bodyParser.parse(request); + try { + const { name, email, password } = await signUpValidator.validate(body); + const cookie = await auth.signUp(name, email, password); + throw redirect("/", { headers: [["set-cookie", cookie]] }); + } catch (error) { + delete body.password; + delete body.password_confirmation; + return handleError(error, { values: body }); + } +}; diff --git a/app/ui/auth/reset-password.route.tsx b/app/ui/auth/reset-password.route.tsx new file mode 100644 index 0000000..25141ce --- /dev/null +++ b/app/ui/auth/reset-password.route.tsx @@ -0,0 +1,93 @@ +import { + Form, + Link, + redirect, + useNavigation, + useSearchParams, +} from "react-router"; + +import { auth } from "~/.server/auth"; +import { Button } from "~/ui/shared/button"; +import { handleError } from "~/.server/response"; +import { bodyParser } from "~/.server/body-parser"; +import { resetPasswordValidator } from "~/.server/validators/user"; + +import type { Route } from "./+types/reset-password.route"; + +import { Input } from "../shared/input"; + +export default function Component({ actionData }: Route.ComponentProps) { + const navigation = useNavigation(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") ?? ""; + return ( +
+
+

Reset Password

+
+ {actionData?.error && ( +

+ {actionData.error.message} +

+ )} + +
+ + +
+
+ + +
+
+ + +
+ +
+

+ Remember your password?{" "} + + Login + +

+
+
+ ); +} + +export const action = async ({ request }: Route.ActionArgs) => { + const body = await bodyParser.parse(request); + try { + const { email, password, token } = await resetPasswordValidator.validate( + body + ); + await auth.resetPassword(email, password, token); + + return redirect("/login"); + } catch (error) { + return handleError(error); + } +}; diff --git a/app/ui/discussion/comment-form.tsx b/app/ui/discussion/comment-form.tsx new file mode 100644 index 0000000..beb89de --- /dev/null +++ b/app/ui/discussion/comment-form.tsx @@ -0,0 +1,41 @@ +import { useCreateCommentFetcher } from "~/api/comments.new"; + +import { Button } from "../shared/button"; +import { Textarea } from "../shared/textarea"; + +export function CommentForm({ + discussionId, + body = "", +}: { + discussionId: number; + body?: string; +}) { + const fetcher = useCreateCommentFetcher(); + + return ( + + +
+ +