initial commit

This commit is contained in:
Joonas 2023-11-09 13:37:40 +02:00
commit c3dfc8e2c6
40 changed files with 8161 additions and 0 deletions

4
.eslintrc.cjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
};

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
/.cache
/build
/public/build
.env

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# Welcome to Remix!
- [Remix Docs](https://remix.run/docs)
## Development
From your terminal:
```sh
npm run dev
```
This starts your app in development mode, rebuilding assets on file changes.
## Deployment
First, build your app for production:
```sh
npm run build
```
Then run the app in production mode:
```sh
npm start
```
Now you'll need to pick a host to deploy it to.
### DIY
If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
Make sure to deploy the output of `remix build`
- `build/`
- `public/build/`

19
app/components/Button.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Text } from "./Typography";
export function Button({
type,
children,
}: {
type: string;
children: ReactNode;
}) {
return (
<button
className="px-6 py-2 rounded-lg border w-fit hover:bg-gray-100"
type={type}
>
<Text>{children}</Text>
</button>
);
}

17
app/components/Card.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { ReactNode } from "react";
export function Card({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={`flex z-10 flex-col bg-gray-100/30 gap-5 p-6 border w-full rounded-lg shadow-inner ${className}`}
>
{children}
</div>
);
}

32
app/components/Form.tsx Normal file
View File

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
export function FormLabel({ children }: { children: ReactNode }) {
return <label className="flex flex-col gap-2">{children}</label>;
}
export function FormInput({ type, name }: { type: string; name: string }) {
return (
<input
className="border text-gray-600 rounded-lg p-2"
type={type}
name={name}
/>
);
}
export function TextArea({
name,
placeholder = "Hello",
}: {
name: string;
cols?: number;
rows?: number;
}) {
return (
<textarea
className="border text-gray-600 w-full rounded-lg p-4"
placeholder={placeholder}
name={name}
></textarea>
);
}

92
app/components/Post.tsx Normal file
View File

@ -0,0 +1,92 @@
import { Link, useFetcher } from "@remix-run/react";
import { Button } from "./Button";
import { Card } from "./Card";
import { FormLabel, TextArea } from "./Form";
import { SubTitle, Text } from "./Typography";
export function PostUsername({ username, name }) {
return (
<div className="flex flex-col">
{name ? (
<>
<SubTitle>{name}</SubTitle>
<Link to={`/users/${username}`}>
<Text type="link">@{username}</Text>
</Link>
</>
) : (
<SubTitle>
<Link to={`/users/${username}`}>
<Text type="link">@{username}</Text>
</Link>
</SubTitle>
)}
</div>
);
}
export function Post({ userId, post }) {
const fetcher = useFetcher();
const liked = post.likes.filter((like) => like.id === userId).length > 0;
return (
<div
key={post.id}
className="relative flex p-6 flex-col gap-2 hover:bg-gray-100/50 transition-all"
>
<div className="flex items-center gap-2">
<PostUsername username={post.author.username} name={post.author.name} />
</div>
<Link to={`/post/${post.id}`}>
<div>
<Text>{post.text}</Text>
</div>
</Link>
<div className="text-sm flex gap-2">
<Text>{new Date(post.createdAt).toLocaleDateString()}</Text>
<fetcher.Form action={`/home`} method="POST">
<input
type="hidden"
name="intent"
value={liked ? "unlike" : "like"}
/>
<input type="hidden" name="postId" value={post.id} />
<button type="submit">
<Text type="link">
{liked ? "Unlike" : "Like"} ({post.likes.length})
</Text>
</button>
</fetcher.Form>
<fetcher.Form action={`/home`} method="POST">
<input type="hidden" name="intent" value={"repost"} />
<input type="hidden" name="postId" value={post.id} />
<button type="submit">
<Text type="link">Repost</Text>
</button>
</fetcher.Form>
<details className="space-y-2">
<summary className="flex cursor-pointer">
<Text type="link">Reply</Text>
</summary>
<Card className="max-w-xs z-100 bg-white absolute">
<SubTitle>Reply to {post.author.username}</SubTitle>
<fetcher.Form
action={`/home`}
className="flex flex-col gap-3"
method="POST"
>
<input type="hidden" name="intent" value={"reply"} />
<input type="hidden" name="postId" value={post.id} />
<FormLabel>
<Text>Reply body</Text>
<TextArea name="reply" />
<Text type="error">-</Text>
</FormLabel>
<Button>Post</Button>
</fetcher.Form>
</Card>
</details>
</div>
</div>
);
}

22
app/components/Toast.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { SubTitle, Text } from "./Typography";
export function Toast({ children }: { children: ReactNode }) {
return (
<>
<input className="peer/toast" type="checkbox" id="toast-toggle" hidden />
<div className="peer-checked/toast:hidden z-10 bg-white w-80 p-4 fixed bottom-10 right-10 border rounded-lg shadow-lg">
<div className="relative">
<label
className="absolute right-0 cursor-pointer text-gray-300 hover:text-gray-400"
htmlFor="toast-toggle"
>
X
</label>
<SubTitle>Alert</SubTitle>
<Text>{children}</Text>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,33 @@
export function Text({ children, type = "", className = "" }) {
return (
<p
className={`${
type === "link"
? "text-sky-400 hover:text-sky-600"
: type === "error"
? "text-red-600/75 text-sm"
: type === "subtitle"
? "text-gray-600 text-sm"
: "text-gray-600"
} ${className}`}
>
{children}
</p>
);
}
export function Title({ children, className = "" }) {
return (
<h1 className={`text-2xl font-bold text-gray-800 ${className}`}>
{children}
</h1>
);
}
export function SubTitle({ children, className = "" }) {
return (
<h2 className={`text-xl font-semibold text-gray-700 ${className}`}>
{children}
</h2>
);
}

18
app/entry.client.tsx Normal file
View File

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

137
app/entry.server.tsx Normal file
View File

@ -0,0 +1,137 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

68
app/models/like.server.ts Normal file
View File

@ -0,0 +1,68 @@
import { prisma } from "~/utils/prisma.server";
import { getPostById } from "./post.server";
export async function likePost(postId: number, id: number) {
const errors = {};
const post = await getPostById(postId);
if (!post) {
errors.postId = "No post with that id";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const like = await prisma.user.update({
where: {
id: id,
},
data: {
likes: {
connect: {
id: post?.id,
},
},
},
});
if (!like) {
errors.all = "Something went wrong liking the post";
return errors;
}
return errors;
}
export async function unLikePost(postId: number, id: number) {
const errors = {};
const post = await getPostById(postId);
if (!post) {
errors.postId = "No post with that id";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const like = await prisma.user.update({
where: {
id: id,
},
data: {
likes: {
disconnect: [{ id: postId }],
},
},
});
if (!like) {
errors.all = "Something went wrong unliking the post";
return errors;
}
return errors;
}

57
app/models/post.server.ts Normal file
View File

@ -0,0 +1,57 @@
import { prisma } from "~/utils/prisma.server";
import type { Post } from "@prisma/client";
export async function getAllPosts() {
const posts = await prisma.post.findMany({
orderBy: {
id: "desc",
},
include: {
author: {
select: {
username: true,
name: true,
},
},
likes: true,
},
});
return posts;
}
export async function getPostById(id: number) {
const post = await prisma.post.findUnique({
where: {
id: id,
},
});
return post;
}
export async function createPost(body: Post["text"], id: number) {
const errors = {};
if (!body) {
errors.body = "Post body can't be empty";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const post = await prisma.post.create({
data: {
text: body,
userId: id,
},
});
if (!post) {
errors.all = "Something went wrong creating the post";
return errors;
}
return errors;
}

188
app/models/user.server.ts Normal file
View File

@ -0,0 +1,188 @@
import bcrypt from "bcryptjs";
import { prisma } from "~/utils/prisma.server";
import type { User } from "@prisma/client";
export async function getUserByName(
username: string,
includePw: boolean = false
) {
const user = await prisma.user.findUnique({
where: {
username: username,
},
select: {
id: true,
username: true,
password: includePw,
name: true,
createdAt: true,
posts: {
include: {
author: true,
likes: true,
},
},
likes: {
include: {
author: true,
likes: true,
},
},
_count: {
select: {
followedBy: true,
following: true,
},
},
},
});
return user;
}
export async function getUserById(id: number) {
const user = await prisma.user.findUnique({
where: {
id: id,
},
});
return user;
}
export async function userExists(username: string) {
return (await getUserByName(username)) ? true : false;
}
export async function editUser(data: object, id: number) {
let errors: {
username: string | undefined;
name: string | undefined;
all: string | undefined;
} = {
username: undefined,
name: undefined,
all: undefined,
};
switch (data.intent) {
case "username":
errors.username = await validateUsername(data.username);
if (Object.values(errors).some(Boolean)) {
return errors;
}
const username = await prisma.user.update({
where: {
id: id,
},
data: {
name: data.username,
},
});
if (!username) {
errors.all = "Something went wrong";
}
return errors;
case "name":
if (!data.name) {
errors.name = "Please enter a valid name";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const name = await prisma.user.update({
where: {
id: id,
},
data: {
name: data.name,
},
});
if (!name) {
errors.all = "Something went wrong";
}
return errors;
default:
break;
}
}
export async function checkUserLogin(username: string, password: string) {
const user = await getUserByName(username, true);
if (!user || !(await bcrypt.compare(password, user.password))) {
return { error: "Invalid credentials" };
}
return { id: user.id, username: user.username, error: "" };
}
async function validatePassword(password: string) {
if (!password) {
return "Enter a password";
}
}
async function validateRepeatPassword(
password: string,
repeatPassword: string
) {
if (password !== repeatPassword) {
return "Passwords need to match";
}
}
async function validateUsername(username: string) {
if (!username) {
return "Enter a username";
}
if (await userExists(username)) {
return "Username taken";
}
}
export async function createUser(
username: User["username"],
password: User["password"],
repeatPassword: User["password"]
) {
let errors: {
username: string | undefined;
password: string | undefined;
rpassword: string | undefined;
all: string | undefined;
} = {
username: await validateUsername(username),
password: await validatePassword(password),
rpassword: await validateRepeatPassword(password, repeatPassword),
all: undefined,
};
if (Object.values(errors).some(Boolean)) {
return { errors: errors };
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username: username,
password: hashedPassword,
},
});
if (!user) {
errors.all = "Failed to create user";
return { errors: errors };
}
return { id: user.id, username: user.username, errors: errors };
}

126
app/root.tsx Normal file
View File

@ -0,0 +1,126 @@
import {
ActionFunctionArgs,
json,
type LinksFunction,
type LoaderFunction,
type LoaderFunctionArgs,
} from "@remix-run/node";
import {
Link,
Links,
LiveReload,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import styles from "./tailwind.css";
import { commitSession, getSession } from "./utils/session.server";
import { SubTitle } from "./components/Typography";
import { Toast } from "./components/Toast";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const message = session.get("globalMessage") || null;
if (session.has("userId")) {
return json(
{
id: session.get("userId"),
username: session.get("username"),
toast: message,
},
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
return json(
{
toast: message,
},
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
export default function App() {
const data = useLoaderData<LoaderFunction>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="subpixel-antialiased min-h-screen">
<div className="m-8 container flex flex-col xl:flex-row gap-20 mx-auto">
<div className="flex-grow w-80">
<nav className="flex flex-col gap-5">
{!data?.id ? (
<>
<NavLink to={`/login`}>
<div className="rounded-lg text-center hover:bg-gray-100 border px-4 py-2">
<SubTitle>Login</SubTitle>
</div>
</NavLink>
<NavLink to={`/register`}>
<div className="rounded-lg text-center hover:bg-gray-100 border px-4 py-2">
<SubTitle>Register</SubTitle>
</div>
</NavLink>
</>
) : (
<>
<NavLink to={`/home`}>
<div className="rounded-lg text-center hover:bg-gray-100 border px-4 py-2">
<SubTitle>Feed</SubTitle>
</div>
</NavLink>
<NavLink to={`/settings`}>
<div className="rounded-lg text-center hover:bg-gray-100 border px-4 py-2">
<SubTitle>Settings</SubTitle>
</div>
</NavLink>
<NavLink to={`/logout`}>
<div className="rounded-lg text-center hover:bg-gray-100 border px-4 py-2">
<SubTitle>Logout</SubTitle>
</div>
</NavLink>
<NavLink to={`/users/${data?.username}`}>
<SubTitle>@{data?.username}</SubTitle>
</NavLink>
</>
)}
</nav>
</div>
<div className="flex-grow-[4] w-full">
<Outlet />
</div>
<div className="flex-grow w-80">
<Link className="flex items-center gap-5" to={"/"}>
<img src="/logo.png" alt="logo" width={50} />
<SubTitle>Twitter clone</SubTitle>
</Link>
</div>
{data?.toast ? <Toast>{data.toast}</Toast> : ""}
</div>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

17
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Text } from "~/components/Typography";
import { getSession } from "~/utils/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) {
return redirect("/home");
}
return null;
}
export default function Index() {
return <Text>Index route for unauthenticated peeps something cool here</Text>;
}

119
app/routes/home.tsx Normal file
View File

@ -0,0 +1,119 @@
import type {
ActionFunction,
ActionFunctionArgs,
LoaderFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Form,
useActionData,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { FormLabel, TextArea } from "~/components/Form";
import { Post } from "~/components/Post";
import { SubTitle, Text, Title } from "~/components/Typography";
import { likePost, unLikePost } from "~/models/like.server";
import { createPost, getAllPosts } from "~/models/post.server";
import { commitSession, getSession } from "~/utils/session.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
const formData = await request
.formData()
.then((data) => Object.fromEntries(data));
if (!userId) {
session.flash("globalMessage", "You need to login before doing that!");
} else {
switch (formData.intent) {
case "newpost":
const postErrors = await createPost(formData.post, userId);
if (Object.values(postErrors).some(Boolean)) {
return postErrors;
}
session.flash("globalMessage", "Post created!");
break;
case "like":
const likeErrors = await likePost(Number(formData.postId), userId);
if (Object.values(likeErrors).some(Boolean)) {
return likeErrors;
}
session.flash("globalMessage", "Post liked!");
break;
case "unlike":
const unLikeErrors = await unLikePost(Number(formData.postId), userId);
if (Object.values(unLikeErrors).some(Boolean)) {
return unLikeErrors;
}
session.flash("globalMessage", "Post unliked!");
break;
default:
return;
}
}
return json(
{},
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
export async function loader({ request }: LoaderFunctionArgs) {
return await getAllPosts();
}
export default function Index() {
const rootData = useRouteLoaderData("root");
const data = useLoaderData<LoaderFunction>();
const errors = useActionData<ActionFunction>();
return (
<div className="space-y-8">
<Card>
{rootData?.id ? (
<>
<Title>New post</Title>
<Form className="flex flex-col gap-3" method="POST">
<input type="hidden" name="intent" value={"newpost"} />
<FormLabel>
<Text>Post body</Text>
<TextArea name="post" />
<Text type="error">{errors?.body ? errors.body : ""}</Text>
</FormLabel>
<Button>Post</Button>
</Form>
</>
) : (
<>
<Title>Twitter clone</Title>
<SubTitle>Made with Remix</SubTitle>
</>
)}
</Card>
<Card className="!p-0 !gap-0 divide-y">
{data.map((post) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
</div>
);
}

73
app/routes/login.tsx Normal file
View File

@ -0,0 +1,73 @@
import {
redirect,
type ActionFunction,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { FormInput, FormLabel } from "~/components/Form";
import { Text, Title } from "~/components/Typography";
import { checkUserLogin } from "~/models/user.server";
import { commitSession, getSession } from "~/utils/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) {
return redirect("/");
}
return null;
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) {
return redirect("/");
}
const formData = await request
.formData()
.then((data) => Object.fromEntries(data));
const { error, id, username } = await checkUserLogin(
formData.username,
formData.password
);
if (error) {
return error;
}
session.set("userId", id);
session.set("username", username);
session.flash("globalMessage", `Logged in to @${username}`);
return redirect("/", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
export default function Login() {
const error = useActionData<ActionFunction>();
return (
<Card>
<Title>Login</Title>
<Form className="flex flex-col gap-3" method="POST">
<FormLabel>
<Text>Username</Text> <FormInput type="text" name="username" />{" "}
<Text type="error">{error ? error : ""}</Text>
</FormLabel>
<FormLabel>
<Text>Password</Text> <FormInput type="password" name="password" />{" "}
<Text type="error">{error ? error : ""}</Text>
</FormLabel>
<Button type="submit">Login</Button>
</Form>
</Card>
);
}

12
app/routes/logout.tsx Normal file
View File

@ -0,0 +1,12 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { destroySession, getSession } from "~/utils/session.server";
export async function loader({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
}

79
app/routes/register.tsx Normal file
View File

@ -0,0 +1,79 @@
import { redirect, json } from "@remix-run/node";
import type {
ActionFunction,
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { commitSession, getSession } from "~/utils/session.server";
import { createUser } from "~/models/user.server";
import { FormInput, FormLabel } from "~/components/Form";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { Text, Title } from "~/components/Typography";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) {
return redirect("/");
}
return null;
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) {
return redirect("/");
}
const formData = await request
.formData()
.then((data) => Object.fromEntries(data));
const { id, username, errors } = await createUser(
formData.username,
formData.password,
formData.rpassword
);
if (Object.values(errors).some(Boolean)) {
return errors;
}
session.set("userId", id);
session.set("username", username);
session.flash("globalMessage", `Registered and logged in to @${username}`);
return redirect("/", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
export default function Register() {
const errors = useActionData<ActionFunction>();
return (
<Card>
<Title>Register</Title>
<Form className="flex flex-col gap-3" method="POST">
<FormLabel>
<Text>Username</Text> <FormInput type="text" name="username" />{" "}
<Text type="error">{errors?.username ? errors.username : ""}</Text>
</FormLabel>
<FormLabel>
<Text>Password</Text> <FormInput type="password" name="password" />{" "}
<Text type="error">{errors?.password ? errors.password : ""}</Text>
</FormLabel>
<FormLabel>
<Text>Repeat password</Text>{" "}
<FormInput type="password" name="rpassword" />{" "}
<Text type="error">{errors?.rpassword ? errors.rpassword : ""}</Text>
</FormLabel>
<Button type="submit">Register</Button>
</Form>
</Card>
);
}

112
app/routes/settings.tsx Normal file
View File

@ -0,0 +1,112 @@
import type {
ActionFunction,
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { FormInput, FormLabel } from "~/components/Form";
import { SubTitle, Title, Text } from "~/components/Typography";
import { editUser } from "~/models/user.server";
import { commitSession, getSession } from "~/utils/session.server";
export async function action({ request }: ActionFunction) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return redirect("/home");
}
const formData = await request
.formData()
.then((data) => Object.fromEntries(data));
const errors = await editUser(formData, session.get("userId"));
if (Object.values(errors).some(Boolean)) {
return errors;
}
session.flash(
"globalMessage",
`Field ${formData.intent} changed successfully!`
);
return json(
{},
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return redirect("/home");
}
return null;
}
export default function Settings() {
const errors = useActionData<ActionFunctionArgs>();
return (
<div className="flex flex-col flex-wrap gap-5">
<Title>Settings</Title>
<div className="flex gap-5">
<Card>
<details className="space-y-2">
<summary className="flex cursor-pointer">
<SubTitle>Change username</SubTitle>
</summary>
<Form method="POST" className="flex flex-col gap-3">
<input type="hidden" name="intent" value="username" />
<FormLabel>
<Text>Username</Text> <FormInput type="text" name="username" />{" "}
<Text type="error">{errors?.name ? errors.name : ""}</Text>
</FormLabel>
<Button type="submit">Submit</Button>
</Form>
</details>
<details className="space-y-2">
<summary className="flex cursor-pointer">
<SubTitle>Change name</SubTitle>
</summary>
<Form method="POST" className="flex flex-col gap-3">
<input type="hidden" name="intent" value="name" />
<FormLabel>
<Text>Name</Text> <FormInput type="text" name="name" />{" "}
<Text type="error">{errors?.name ? errors.name : ""}</Text>
</FormLabel>
<Button type="submit">Submit</Button>
</Form>
</details>
<details className="space-y-2">
<summary className="flex cursor-pointer">
<SubTitle>Change password</SubTitle>
</summary>
<Form method="POST" className="flex flex-col gap-3">
<input type="hidden" name="intent" value="password" />
<FormLabel>
<Text>Old password</Text>{" "}
<FormInput type="text" name="opassword" />{" "}
<Text type="error"></Text>
</FormLabel>
<FormLabel>
<Text>New password</Text>{" "}
<FormInput type="text" name="npassword" />{" "}
<Text type="error"></Text>
</FormLabel>
<Button type="submit">Submit</Button>
</Form>
</details>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import type { LoaderFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Link, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { Post, PostUsername } from "~/components/Post";
import { SubTitle, Text, Title } from "~/components/Typography";
import { getUserByName } from "~/models/user.server";
export async function loader({ params }: LoaderFunctionArgs) {
return await getUserByName(params.username);
}
export default function UserProfile() {
const rootData = useRouteLoaderData("root");
const data = useLoaderData<LoaderFunction>();
console.log(data);
return (
<div className="flex flex-col gap-5">
<Title>Profile</Title>
<Card>
<div className="flex w-full">
<div className="flex flex-1 items-center gap-2">
<PostUsername username={data.username} name={data.name} />
</div>
<div>
<Button>Follow</Button>
</div>
</div>
<div>
<Text>
description goes here, posts etc go down ward Lorem ipsum dolor sit
amet, consectetur adipiscing elit. Curabitur rutrum augue in massa
pretium fringilla. Sed faucibus mi felis, in lobortis mi fermentum
at. Proin quis sagittis lacus. Donec pharetra ante dolor, sed semper
lectus dictum vel. Phasellus a pulvinar sapien. In dapibus ex vitae
pretium consequat. Class aptent taciti sociosqu ad litora torquent
per conubia nostra, per inceptos himenaeos. Sed volutpat justo id
risus placerat placerat. Proin ex elit, pulvinar ac elit sed,
egestas ultrices dolor. In elementum eros metus. Fusce placerat enim
eu ipsum semper feugiat. Curabitur consequat, odio et auctor
imperdiet, nisl dui dignissim felis, porttitor vulputate elit mauris
et lacus. Vivamus sit amet pharetra est. Vivamus pretium hendrerit
arcu. Sed massa augue, rhoncus et ante vitae, posuere sollicitudin
massa.
</Text>
</div>
<div className="flex gap-5">
<div className="flex items-center gap-2">
<Text type="subtitle">Followers:</Text>
<Link to={`/users/${data.username}/followers`}>
<Text type="link">{data._count.followedBy}</Text>
</Link>
</div>
<div className="flex items-center gap-2">
<Text type="subtitle">Follows:</Text>
<Link to={`/users/${data.username}/following`}>
<Text type="link">{data._count.following}</Text>
</Link>
</div>
</div>
</Card>
<div className="flex flex-col md:flex-row gap-10">
<div className="flex w-full flex-col gap-5 flex-grow">
<SubTitle>Posts</SubTitle>
<Card className="!p-0 !gap-0 divide-y">
{data.posts.map((post) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
</div>
<div className="flex w-full flex-col gap-5 flex-grow">
<SubTitle>Likes</SubTitle>
<Card className="!p-0 !gap-0 divide-y">
{data.likes.map((post) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
</div>
</div>
</div>
);
}

3
app/tailwind.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,19 @@
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
prisma.$connect();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
global.__db.$connect();
}
prisma = global.__db;
}
export { prisma };

View File

@ -0,0 +1,10 @@
import { createCookieSessionStorage } from "@remix-run/node";
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: "__session",
path: "/",
sameSite: "lax",
},
});

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "twitter-clone",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix build",
"dev": "remix dev --manual",
"start": "remix-serve ./build/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@prisma/client": "5.5.2",
"@remix-run/css-bundle": "^2.2.0",
"@remix-run/node": "^2.2.0",
"@remix-run/react": "^2.2.0",
"@remix-run/serve": "^2.2.0",
"bcryptjs": "^2.4.3",
"isbot": "^3.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.2.0",
"@remix-run/eslint-config": "^2.2.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/bcryptjs": "^2.4.5",
"eslint": "^8.38.0",
"prisma": "^5.5.2",
"tailwindcss": "^3.3.5",
"typescript": "^5.1.6"
},
"engines": {
"node": ">=18.0.0"
}
}

6559
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT
);
-- CreateTable
CREATE TABLE "Post" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"text" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "_UserFollows" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_UserFollows_A_fkey" FOREIGN KEY ("A") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_UserFollows_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B");
-- CreateIndex
CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B");

View File

@ -0,0 +1,34 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Post` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Post" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"text" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Post" ("id", "text", "userId") SELECT "id", "text", "userId" FROM "Post";
DROP TABLE "Post";
ALTER TABLE "new_Post" RENAME TO "Post";
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("id", "name", "password", "username") SELECT "id", "name", "password", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "UserPostLike" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"postId" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserPostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "UserPostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

View File

@ -0,0 +1,24 @@
/*
Warnings:
- You are about to drop the `UserPostLike` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "UserPostLike";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "_UserLikes" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_UserLikes_A_fkey" FOREIGN KEY ("A") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_UserLikes_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "_UserLikes_AB_unique" ON "_UserLikes"("A", "B");
-- CreateIndex
CREATE INDEX "_UserLikes_B_index" ON "_UserLikes"("B");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

36
prisma/schema.prisma Normal file
View File

@ -0,0 +1,36 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
name String?
posts Post[] @relation("UserPosts")
likes Post[] @relation("UserLikes")
following User[] @relation("UserFollows")
followedBy User[] @relation("UserFollows")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id Int @id @default(autoincrement())
text String
author User @relation("UserPosts", fields: [userId], references: [id])
userId Int
likes User[] @relation("UserLikes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

9
remix.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/.*"],
browserNodeBuiltinsPolyfill: { modules: { crypto: true } },
// appDirectory: "app",
// assetsBuildDirectory: "public/build",
// publicPath: "/build/",
// serverBuildPath: "build/index.js",
};

2
remix.env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

9
tailwind.config.ts Normal file
View File

@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}