Add following and unfollowing, profile descriptions, feed, various refactors, layout fixes and minor fixes

This commit is contained in:
Joonas 2023-11-13 12:42:26 +02:00
parent 073d4aeeaf
commit 5a9897ef38
14 changed files with 376 additions and 109 deletions

View File

@ -6,7 +6,7 @@ export function Button({
children,
}: {
type: "button" | "submit" | "reset";
children: ReactNode;
children?: ReactNode;
}) {
return (
<button

View File

@ -9,7 +9,7 @@ export function Card({
}) {
return (
<div
className={`flex z-10 flex-col bg-gray-50/9 gap-5 p-6 border shadow-lg w-full rounded-lg ${className}`}
className={`flex z-10 flex-col bg-gray-50/9 gap-5 p-8 border w-full rounded-lg ${className}`}
>
{children}
</div>

View File

@ -18,7 +18,7 @@ export function Poster({
className?: string;
}) {
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className={`flex break-all items-center gap-3 ${className}`}>
<img
className="rounded-full border w-12"
src={
@ -26,7 +26,7 @@ export function Poster({
}
alt="pfp"
/>
<div className="break-all">
<div>
{name ? (
<>
<SubTitle>{name}</SubTitle>

View File

@ -18,6 +18,8 @@ export function Text({
? "text-red-600/75 text-sm"
: type === "subtitle"
? "text-gray-600 text-sm"
: type === "success"
? "text-green-600/75"
: "text-gray-600"
} ${className}`}
>
@ -43,12 +45,18 @@ export function Title({
export function SubTitle({
children,
className = "",
type,
}: {
children: ReactNode;
className?: string;
type?: "link";
}) {
return (
<h2 className={`text-xl font-semibold text-gray-700 ${className}`}>
<h2
className={`text-xl font-semibold ${
type ? "text-sky-600 hover:text-sky-800" : "text-gray-700"
} ${className}`}
>
{children}
</h2>
);

View File

@ -21,6 +21,32 @@ export async function getAllPosts() {
return posts;
}
export async function getFeed(id: number) {
const following = await prisma.user.findFirst({
where: { id: id },
select: { following: { select: { id: true } } },
});
const posts = await prisma.post.findMany({
where: {
userId: {
in: [...following.following.map((user) => user.id)],
},
},
include: {
author: {
include: {
likes: true,
posts: true,
},
},
likes: true,
},
});
return posts;
}
export async function getPostById(id: number) {
const post = await prisma.post.findUnique({
where: {

View File

@ -17,8 +17,14 @@ export async function getUserByName(
username: true,
password: includePw,
name: true,
desc: true,
pfp: true,
createdAt: true,
followedBy: {
select: {
id: true,
},
},
posts: {
include: {
author: true,
@ -60,17 +66,83 @@ export async function userExists(username: string) {
export interface UserErrors {
username: string | undefined;
name: string | undefined;
desc: string | undefined;
opassword: string | undefined;
npassword: string | undefined;
pfp: string | undefined;
all: string | undefined;
}
export async function followUser(followId: number, id: number) {
const errors: { followId: string | undefined; all: string | undefined } = {
followId: undefined,
all: undefined,
};
if (!(await getUserById(followId))) {
errors.followId = "User does not exist";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const follow = await prisma.user.update({
where: {
id: id,
},
data: {
following: {
connect: { id: followId },
},
},
});
if (!follow) {
errors.all = "Something went wrong";
}
return errors;
}
export async function unFollowUser(followId: number, id: number) {
const errors: { followId: string | undefined; all: string | undefined } = {
followId: undefined,
all: undefined,
};
if (!(await getUserById(followId))) {
errors.followId = "User does not exist";
}
if (Object.values(errors).some(Boolean)) {
return errors;
}
const unFollow = await prisma.user.update({
where: {
id: id,
},
data: {
following: {
disconnect: { id: followId },
},
},
});
if (!unFollow) {
errors.all = "Something went wrong";
}
return errors;
}
export async function editUser(
data: {
intent: string;
username: string;
name: string;
desc: string;
opassword: string;
npassword: string;
pfp: {
@ -83,6 +155,7 @@ export async function editUser(
let errors: UserErrors = {
username: undefined,
name: undefined,
desc: undefined,
opassword: undefined,
npassword: undefined,
pfp: undefined,
@ -137,6 +210,29 @@ export async function editUser(
errors.all = "Something went wrong";
}
return { errors: errors };
case "desc":
if (!data.desc) {
errors.desc = "Please enter a valid description";
}
if (Object.values(errors).some(Boolean)) {
return { errors: errors };
}
const desc = await prisma.user.update({
where: {
id: id,
},
data: {
desc: data.desc,
},
});
if (!desc) {
errors.all = "Something went wrong";
}
return { errors: errors };
case "password":
if (!data.opassword) {

View File

@ -95,8 +95,8 @@ export default function App() {
</head>
<body className="min-h-screen subpixel-antialiased">
<div className="container flex flex-col gap-20 m-8 mx-auto xl:flex-row">
<div className="flex-grow w-80">
<nav className="flex flex-col gap-5">
<div className="flex mx-auto justify-center flex-grow w-80">
<nav className="flex w-full flex-col gap-5">
{!data?.id ? (
<>
<NavItem text={`Login`} to={`/login`} />
@ -120,12 +120,14 @@ export default function App() {
<div className="flex-grow-[4] w-full">
<Outlet />
</div>
<div className="flex-grow w-80">
<div className="hover:bg-sky-100/50 rounded-lg transition-all p-4">
<Link className="flex flex-col gap-5 items-center" to={"/"}>
<img src="/logo.png" alt="logo" width={50} />
<SubTitle>Twitter clone</SubTitle>
</Link>
<div className="flex mx-auto justify-center flex-grow w-80">
<div>
<div className="hover:bg-sky-100/50 rounded-lg transition-all p-4">
<Link className="flex flex-col gap-5 items-center" to={"/"}>
<img src="/logo.png" alt="logo" width={50} />
<SubTitle>Twitter clone</SubTitle>
</Link>
</div>
</div>
</div>
{data?.toast ? <Toast>{data.toast}</Toast> : ""}
@ -141,7 +143,7 @@ export default function App() {
function NavItem({ to, text }: { to: string; text: string }) {
return (
<NavLink to={to}>
<div className="px-4 py-2 text-center rounded-lg border hover:bg-gray-100">
<div className="px-4 w-full py-2 text-center rounded-lg border hover:bg-gray-100">
<SubTitle>{text}</SubTitle>
</div>
</NavLink>

View File

@ -18,7 +18,7 @@ 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 { createPost, getAllPosts, getFeed } from "~/models/post.server";
import { commitSession, getSession } from "~/utils/session.server";
import type { RootLoaderTypes } from "~/root";
@ -34,44 +34,41 @@ export async function action({ request }: ActionFunctionArgs) {
.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(String(formData.post), userId);
switch (formData.intent) {
case "newpost":
const postErrors = await createPost(String(formData.post), userId);
if (Object.values(postErrors).some(Boolean)) {
return postErrors;
}
if (Object.values(postErrors).some(Boolean)) {
return postErrors;
}
session.flash("globalMessage", "Post created!");
session.flash("globalMessage", "Post created!");
break;
case "like":
const likeErrors = await likePost(Number(formData.postId), userId);
break;
case "like":
const likeErrors = await likePost(Number(formData.postId), userId);
if (Object.values(likeErrors).some(Boolean)) {
return likeErrors;
}
if (Object.values(likeErrors).some(Boolean)) {
return likeErrors;
}
session.flash("globalMessage", "Post liked!");
session.flash("globalMessage", "Post liked!");
break;
case "unlike":
const unLikeErrors = await unLikePost(Number(formData.postId), userId);
break;
case "unlike":
const unLikeErrors = await unLikePost(Number(formData.postId), userId);
if (Object.values(unLikeErrors).some(Boolean)) {
return unLikeErrors;
}
if (Object.values(unLikeErrors).some(Boolean)) {
return unLikeErrors;
}
session.flash("globalMessage", "Post unliked!");
session.flash("globalMessage", "Post unliked!");
break;
default:
return;
}
break;
default:
return;
}
return json(
{},
{
@ -88,14 +85,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect("/");
}
return await getAllPosts();
return await getFeed(session.get("userId"));
}
export default function Index() {
const rootData = useRouteLoaderData<RootLoaderTypes>("root");
const data = useLoaderData<LoaderFunction>();
const data: PostWithRelations[] = useLoaderData<LoaderFunction>();
const errors = useActionData<ActionFunction>();
console.log(data);
return (
<div className="flex flex-col gap-5">
<Title>Home</Title>
@ -120,11 +119,18 @@ export default function Index() {
</>
)}
</Card>
<Card className="!p-0 !gap-0 divide-y">
{data.map((post: PostWithRelations) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
<SubTitle>Feed</SubTitle>
{data.length > 0 ? (
<Card className="!p-0 !gap-0 divide-y">
{data.map((post: PostWithRelations) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
) : (
<Text type="error">
Feed is empty, follow someone so their recent posts will show up here!
</Text>
)}
</div>
);
}

View File

@ -66,7 +66,7 @@ export default function Login() {
<Text>Password</Text> <FormInput type="password" name="password" />{" "}
<Text type="error">{error ? error : ""}</Text>
</FormLabel>
<Button type="submit">Login</Button>
<Button type="submit" />
</Form>
</Card>
);

View File

@ -55,6 +55,7 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Register() {
const errors = useActionData<ActionFunction>();
return (
<Card>
<Title>Register</Title>

View File

@ -41,20 +41,21 @@ export async function action({ request }: ActionFunctionArgs) {
const { username, errors } = await editUser(formData, session.get("userId"));
if (Object.values(errors).some(Boolean)) {
return errors;
return { errors: errors };
}
if (username) {
session.set("username", username);
}
session.flash(
"globalMessage",
`Field ${formData.intent} changed successfully!`
);
return json(
{},
{
success: {
[String(
formData.intent
)]: `Field ${formData.intent} changed successfully!`,
},
},
{
headers: {
"Set-Cookie": await commitSession(session),
@ -73,86 +74,142 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Settings() {
const errors = useActionData<ActionFunction>();
const data = useActionData<ActionFunction>();
return (
<div className="flex flex-col gap-5">
<Title>Settings</Title>
<div className="flex flex-col gap-5">
<details className="space-y-4">
<summary className="flex cursor-pointer select-none">
<SubTitle>Change username</SubTitle>
<summary className="text-gray-600 cursor-pointer select-none">
Change username
</summary>
<Card>
<Form method="POST" className="flex flex-col gap-3">
<Form method="POST" className="relative 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?.username ? errors.username : ""}
{data?.errors?.username ? data.errors.username : ""}
</Text>
</FormLabel>
<Button type="submit">Submit</Button>
{data?.success?.username ? (
<Text className="!absolute right-0" type="success">
{data.success.username}
</Text>
) : (
""
)}
</Form>
</Card>
</details>
<details className="space-y-4">
<summary className="flex cursor-pointer select-none">
<SubTitle>Change name</SubTitle>
<summary className="text-gray-600 cursor-pointer select-none">
Change name
</summary>
<Card>
<Form method="POST" className="flex flex-col gap-3">
<Form method="POST" className="relative 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>
<Text type="error">
{data?.errors?.name ? data.errors.name : ""}
</Text>
</FormLabel>
<Button type="submit">Submit</Button>
{data?.success?.name ? (
<Text className="!absolute right-0" type="success">
{data.success.name}
</Text>
) : (
""
)}
</Form>
</Card>
</details>
<details className="space-y-4">
<summary className="flex cursor-pointer select-none">
<SubTitle>Change password</SubTitle>
<summary className="text-gray-600 cursor-pointer select-none">
Change description
</summary>
<Card>
<Form method="POST" className="flex flex-col gap-3">
<Form method="POST" className="relative flex flex-col gap-3">
<input type="hidden" name="intent" value="desc" />
<FormLabel>
<Text>Description</Text> <FormInput type="text" name="desc" />{" "}
<Text type="error">
{data?.errors?.desc ? data.errors.desc : ""}
</Text>
</FormLabel>
<Button type="submit">Submit</Button>
{data?.success?.desc ? (
<Text className="!absolute right-0" type="success">
{data.success.desc}
</Text>
) : (
""
)}
</Form>
</Card>
</details>
<details className="space-y-4">
<summary className="text-gray-600 cursor-pointer select-none">
Change password
</summary>
<Card>
<Form method="POST" className="relative 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">
{errors?.opassword ? errors.opassword : ""}
{data?.errors?.opassword ? data.errors.opassword : ""}
</Text>
</FormLabel>
<FormLabel>
<Text>New password</Text>{" "}
<FormInput type="text" name="npassword" />{" "}
<Text type="error">
{errors?.npassword ? errors.npassword : ""}
{data?.errors?.npassword ? data.errors.npassword : ""}
</Text>
</FormLabel>
<Button type="submit">Submit</Button>
{data?.success?.password ? (
<Text className="!absolute right-0" type="success">
{data.success.password}
</Text>
) : (
""
)}
</Form>
</Card>
</details>
<details className="space-y-4">
<summary className="flex cursor-pointer select-none">
<SubTitle>Change profile picture</SubTitle>
<summary className="text-gray-600 cursor-pointer select-none">
Change profile picture
</summary>
<Card>
<Form
method="POST"
encType="multipart/form-data"
className="flex flex-col gap-3"
className="relative flex flex-col gap-3"
>
<input type="hidden" name="intent" value="pfp" />
<FormLabel>
<Text>New profile picture</Text>{" "}
<FormInput type="file" name="pfp" />{" "}
<Text type="error">{errors?.pfp ? errors.pfp : ""}</Text>
<Text type="error">
{data?.errors?.pfp ? data.errors.pfp : ""}
</Text>
</FormLabel>
<Button type="submit">Submit</Button>
{data?.success?.pfp ? (
<Text className="!absolute right-0" type="success">
{data.success.pfp}
</Text>
) : (
""
)}
</Form>
</Card>
</details>

View File

@ -1,12 +1,63 @@
import type { LoaderFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Link, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import type {
ActionFunction,
LoaderFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import {
Form,
Link,
useActionData,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { Button } from "~/components/Button";
import { Card } from "~/components/Card";
import { Post, Poster } from "~/components/Post";
import { SubTitle, Text, Title } from "~/components/Typography";
import { getUserByName } from "~/models/user.server";
import { followUser, getUserByName, unFollowUser } from "~/models/user.server";
import type { RootLoaderTypes } from "~/root";
import type { UserWithRelations } from "~/utils/prisma.server";
import { getSession } from "~/utils/session.server";
export async function action({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return;
}
const formData = await request
.formData()
.then((data) => Object.fromEntries(data));
switch (formData.intent) {
case "follow":
const followErrors = await followUser(
Number(formData.id),
session.get("userId")
);
if (Object.values(followErrors).some(Boolean)) {
return followErrors;
}
break;
case "unfollow":
const unFollowErrors = await unFollowUser(
Number(formData.id),
session.get("userId")
);
if (Object.values(unFollowErrors).some(Boolean)) {
return unFollowErrors;
}
break;
default:
break;
}
return null;
}
export async function loader({ params }: LoaderFunctionArgs) {
return await getUserByName(params.username);
@ -15,6 +66,9 @@ export async function loader({ params }: LoaderFunctionArgs) {
export default function UserProfile() {
const rootData = useRouteLoaderData<RootLoaderTypes>("root");
const data: UserWithRelations = useLoaderData<LoaderFunction>();
const actionData = useActionData<ActionFunction>();
const isFollowing =
data.followedBy.filter((follow) => follow.id === rootData?.id).length > 0;
return (
<div className="flex flex-col gap-5">
@ -24,28 +78,31 @@ export default function UserProfile() {
<div className="flex flex-1 gap-2 items-center">
<Poster pfp={data.pfp} username={data.username} name={data.name} />
</div>
{rootData?.id && rootData?.id !== data.id ? (
<div>
<Form method="POST">
<input
type="hidden"
name="intent"
value={isFollowing ? "unfollow" : "follow"}
/>
<input type="hidden" name="id" value={data.id} />
<Button type="submit">
{isFollowing ? "Following!" : "Follow"}
</Button>
</Form>
</div>
) : (
""
)}
</div>
{data?.desc ? (
<div>
<Button type="button">Follow</Button>
<Text>{data.desc}</Text>
</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 gap-2 items-center">
<Text type="subtitle">Followers:</Text>
@ -64,19 +121,27 @@ export default function UserProfile() {
<div className="flex flex-col gap-10 md:flex-row">
<div className="flex flex-col flex-grow gap-5 w-full">
<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>
{data.posts.length > 0 ? (
<Card className="!p-0 !gap-0 divide-y">
{data.posts.map((post) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
) : (
<Text type="subtitle">No posts yet</Text>
)}
</div>
<div className="flex flex-col flex-grow gap-5 w-full">
<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>
{data.likes.length > 0 ? (
<Card className="!p-0 !gap-0 divide-y">
{data.likes.map((post) => (
<Post userId={rootData?.id} key={post.id} post={post} />
))}
</Card>
) : (
<Text type="subtitle">No likes yet</Text>
)}
</div>
</div>
</div>

View File

@ -7,6 +7,11 @@ export type PostWithRelations = Prisma.PostGetPayload<{
}>;
export type UserWithRelations = Prisma.UserGetPayload<{
include: {
followedBy: {
select: {
id: true;
};
};
posts: {
include: {
author: true;

View File

@ -15,6 +15,7 @@ model User {
username String @unique
password String
name String?
desc String?
pfp String
posts Post[] @relation("UserPosts")
likes Post[] @relation("UserLikes")