Compare commits
4 Commits
67069c2551
...
3906439f76
Author | SHA1 | Date |
---|---|---|
Joonas | 3906439f76 | |
Joonas | 9293a43d9c | |
Joonas | 35f42d5fdc | |
Joonas | 084fa4fa53 |
|
@ -3,6 +3,7 @@ node_modules
|
|||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
/public/uploads
|
||||
.env
|
||||
|
||||
prisma/dev.db
|
|
@ -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..."
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -11,6 +11,7 @@ export async function getAllPosts() {
|
|||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
pfp: true,
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
34
app/root.tsx
34
app/root.tsx
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
);
|
|
@ -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;
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue