Compare commits

...

4 Commits

Author SHA1 Message Date
Joonas 3906439f76 More profile picture stuff 2023-11-09 23:20:27 +02:00
Joonas 9293a43d9c Access control and refactor root loader for Poster component 2023-11-09 23:19:28 +02:00
Joonas 35f42d5fdc Form validation 2023-11-09 23:18:45 +02:00
Joonas 084fa4fa53 Add profile pictures 2023-11-09 23:18:07 +02:00
13 changed files with 232 additions and 41 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ node_modules
/.cache
/build
/public/build
/public/uploads
.env
prisma/dev.db

View File

@ -7,9 +7,12 @@ export function FormLabel({ children }: { children: ReactNode }) {
export function FormInput({ type, name }: { type: string; name: string }) {
return (
<input
className="p-2 text-gray-600 rounded-lg border"
className={`file:bg-transparent file:border-0 ${
type === "file" ? "p-4 cursor-pointer" : "p-2"
} file:bg-sky-100 file:hover:bg-sky-100/75 file:text-gray-600/75 file:p-2 file:rounded-lg text-gray-600 rounded-lg border`}
type={type}
name={name}
accept={type === "file" ? "image/*" : undefined}
placeholder="Hello world..."
/>
);

View File

@ -4,23 +4,43 @@ import { Card } from "./Card";
import { FormLabel, TextArea } from "./Form";
import { SubTitle, Text } from "./Typography";
export function PostUsername({ username, name }) {
export function Poster({
username,
name,
pfp,
className,
}: {
username: string;
name: string;
pfp: string;
className?: string;
}) {
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 className={`flex items-center gap-3 ${className}`}>
<img
className="rounded-full border"
src={
pfp ? `/uploads/${encodeURIComponent(pfp)}` : `/uploads/default.png`
}
alt="pfp"
width={50}
/>
<div className="break-all">
{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>
</div>
);
}
@ -34,8 +54,12 @@ export function Post({ userId, post }) {
key={post.id}
className="flex relative flex-col gap-4 p-6 transition-all hover:bg-gray-100/50"
>
<div className="flex gap-2 items-center">
<PostUsername username={post.author.username} name={post.author.name} />
<div className="flex items-center">
<Poster
pfp={post.author.pfp}
username={post.author.username}
name={post.author.name}
/>
</div>
<Link to={`/post/${post.id}`}>
<div>

View File

@ -11,6 +11,7 @@ export async function getAllPosts() {
select: {
username: true,
name: true,
pfp: true,
},
},
likes: true,

View File

@ -1,6 +1,8 @@
import bcrypt from "bcryptjs";
import { prisma } from "~/utils/prisma.server";
import type { User } from "@prisma/client";
import { unlink } from "node:fs";
import { redirect } from "@remix-run/node";
export async function getUserByName(
username: string,
@ -15,6 +17,7 @@ export async function getUserByName(
username: true,
password: includePw,
name: true,
pfp: true,
createdAt: true,
posts: {
include: {
@ -54,18 +57,22 @@ export async function userExists(username: string) {
return (await getUserByName(username)) ? true : false;
}
export interface UserErrors {
username: string | undefined;
name: string | undefined;
opassword: string | undefined;
npassword: string | undefined;
pfp: string | undefined;
all: string | undefined;
}
export async function editUser(data: object, id: number) {
let errors: {
username: string | undefined;
name: string | undefined;
opassword: string | undefined;
npassword: string | undefined;
all: string | undefined;
} = {
let errors: UserErrors = {
username: undefined,
name: undefined,
opassword: undefined,
npassword: undefined,
pfp: undefined,
all: undefined,
};
@ -96,6 +103,10 @@ export async function editUser(data: object, id: number) {
errors.name = "Please enter a valid name";
}
if (data.name.length > 32) {
errors.name = "Too many characters";
}
if (Object.values(errors).some(Boolean)) {
return { errors: errors };
}
@ -146,9 +157,44 @@ export async function editUser(data: object, id: number) {
errors.all = "Something went wrong";
}
return { errors: errors };
case "pfp":
if (!data.pfp.filepath) {
errors.pfp = "Failed saving image";
}
if (Object.values(errors).some(Boolean)) {
return { errors: errors };
}
const userpfp = await getUserById(id);
if (!userpfp) {
return redirect("/logout");
}
if (userpfp.pfp !== "default.png") {
unlink(`public/uploads/${userpfp.pfp}`, (err) => {
if (err) console.log(err);
});
}
const pfp = await prisma.user.update({
where: {
id: id,
},
data: {
pfp: data.pfp.name,
},
});
if (!pfp) {
errors.pfp = "Failed saving image";
}
return { errors: errors };
default:
break;
errors.all = "Doesn't exist";
return { errors: errors };
}
}
@ -185,6 +231,10 @@ async function validateUsername(username: string) {
return "Enter a username";
}
if (username.length > 16) {
return "Too many characters";
}
if (await userExists(username)) {
return "Username taken";
}
@ -219,6 +269,7 @@ export async function createUser(
data: {
username: username,
password: await createPasswordHash(password),
pfp: "default.png",
},
});

View File

@ -1,6 +1,6 @@
import {
ActionFunctionArgs,
json,
redirect,
type LinksFunction,
type LoaderFunction,
type LoaderFunctionArgs,
@ -21,6 +21,8 @@ import styles from "./tailwind.css";
import { commitSession, getSession } from "./utils/session.server";
import { SubTitle } from "./components/Typography";
import { Toast } from "./components/Toast";
import { prisma } from "./utils/prisma.server";
import { Poster } from "./components/Post";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
@ -28,10 +30,28 @@ export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const message = session.get("globalMessage") || null;
if (session.has("userId")) {
const data = await prisma.user.findUnique({
where: {
id: session.get("userId"),
},
select: {
id: true,
username: true,
name: true,
pfp: true,
},
});
if (!data) {
return redirect("/logout");
}
return json(
{
id: session.get("userId"),
username: session.get("username"),
id: data.id,
username: data.username,
name: data.name,
pfp: data.pfp,
toast: message,
},
{
@ -79,9 +99,11 @@ export default function App() {
<NavItem text={`Home`} to={`home`} />
<NavItem text={`Settings`} to={`settings`} />
<NavItem text={`Logout`} to={`logout`} />
<NavItem
text={`@${data?.username}`}
to={`/users/${data?.username}`}
<Poster
className="justify-center"
username={data.username}
name={data.name}
pfp={data.pfp}
/>
</>
)}

View File

@ -16,6 +16,7 @@ export default function Index() {
return (
<div className="bg-sky-100 rounded-lg">
<div className="flex flex-col gap-1 h-96 justify-center items-center">
<img src="/logo.png" alt="twitter" width={64} />
<h1 className="text-7xl font-black">
<span className="text-sky-400">Twitter</span>{" "}
<span className="">Clone</span>

View File

@ -4,7 +4,7 @@ import type {
LoaderFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
useActionData,
@ -22,6 +22,9 @@ import { commitSession, getSession } from "~/utils/session.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return redirect("/");
}
const userId = session.get("userId");
@ -78,6 +81,11 @@ export async function action({ request }: ActionFunctionArgs) {
}
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return redirect("/");
}
return await getAllPosts();
}

View File

@ -3,25 +3,41 @@ import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
json,
redirect,
unstable_parseMultipartFormData,
} 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 { UserErrors, editUser } from "~/models/user.server";
import { profilePictureUploadHandler } from "~/utils/file.server";
import { commitSession, getSession } from "~/utils/session.server";
export async function action({ request }: ActionFunction) {
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return redirect("/home");
}
const formData = await request
let req = request.clone();
let formData = await request
.formData()
.then((data) => Object.fromEntries(data));
if (formData.intent === "pfp") {
try {
formData = Object.fromEntries(
await unstable_parseMultipartFormData(req, profilePictureUploadHandler)
);
} catch (e) {
console.log(e);
}
}
const { username, errors } = await editUser(formData, session.get("userId"));
if (Object.values(errors).some(Boolean)) {
@ -57,7 +73,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Settings() {
const errors = useActionData<ActionFunctionArgs>();
const errors = useActionData<ActionFunction>();
return (
<div className="flex flex-col gap-5">
@ -120,6 +136,26 @@ export default function Settings() {
</Form>
</Card>
</details>
<details className="space-y-4">
<summary className="flex cursor-pointer select-none">
<SubTitle>Change profile picture</SubTitle>
</summary>
<Card>
<Form
method="POST"
encType="multipart/form-data"
className="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>
</FormLabel>
<Button type="submit">Submit</Button>
</Form>
</Card>
</details>
</div>
</div>
);

View File

@ -2,7 +2,7 @@ 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 { Post, Poster } from "~/components/Post";
import { SubTitle, Text, Title } from "~/components/Typography";
import { getUserByName } from "~/models/user.server";
@ -22,7 +22,7 @@ export default function UserProfile() {
<Card>
<div className="flex w-full">
<div className="flex flex-1 gap-2 items-center">
<PostUsername username={data.username} name={data.name} />
<Poster pfp={data.pfp} username={data.username} name={data.name} />
</div>
<div>
<Button>Follow</Button>

20
app/utils/file.server.ts Normal file
View File

@ -0,0 +1,20 @@
import {
unstable_composeUploadHandlers,
unstable_createFileUploadHandler,
unstable_createMemoryUploadHandler,
} from "@remix-run/node";
export const profilePictureUploadHandler = unstable_composeUploadHandlers(
unstable_createFileUploadHandler({
directory: "public/uploads",
maxPartSize: 1000000,
file({ filename }) {
return filename;
},
filter({ contentType }) {
return contentType.includes("image");
},
}),
// parse everything else into memory
unstable_createMemoryUploadHandler()
);

View File

@ -0,0 +1,23 @@
/*
Warnings:
- Added the required column `pfp` 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_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT,
"pfp" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "id", "name", "password", "updatedAt", "username") SELECT "createdAt", "id", "name", "password", "updatedAt", "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

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