initial commit
This commit is contained in:
commit
c3dfc8e2c6
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
|
@ -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/`
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -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 };
|
|
@ -0,0 +1,10 @@
|
|||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
|
||||
export const { getSession, commitSession, destroySession } =
|
||||
createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
},
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
@ -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");
|
|
@ -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;
|
|
@ -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
|
||||
);
|
|
@ -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");
|
|
@ -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"
|
|
@ -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
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
|
@ -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",
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@remix-run/node" />
|
|
@ -0,0 +1,9 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue