imageboard/app/routes/threads/$threadId.jsx

296 lines
8.4 KiB
JavaScript

import { redirect } from "@remix-run/node";
import {
unstable_createFileUploadHandler,
unstable_parseMultipartFormData,
json,
} from "@remix-run/node";
import {
useLoaderData,
Form,
useActionData,
Link,
useTransition,
} from "@remix-run/react";
import { useEffect, useState } from "react";
import Overlay from "~/components/Overlay";
import { ThreadReply } from "~/components/ThreadReply";
import prisma from "~/utils/db.server";
import { useNavigate } from "@remix-run/react";
import { useRevalidator } from "@remix-run/react";
export async function action({ request, params }) {
const threadId = params.threadId;
if (!parseInt(threadId)) throw new Error("Bad route parameter");
const clonedData = request.clone();
const formData = await clonedData.formData();
const post = formData.get("post");
const replying = formData.get("replying");
const fileUploadHandler = unstable_createFileUploadHandler({
directory: "./public/uploads/",
maxPartSize: 500000,
file: ({ filename }) => {
if (formData.get("anonymize"))
return `${require("crypto").randomBytes(16).toString("hex")}.${
filename.split(".").slice(-1)[0]
}`;
return filename;
},
filter: (data) => {
const fileTypes = ["jpeg", "jpg", "png", "gif"];
// if sent file is not an image, don't handle it
if (!fileTypes.includes(data.contentType.split("/")[1])) return false;
return true;
},
});
const errors = {};
let imageName;
let multiPartformdata;
try {
multiPartformdata = await unstable_parseMultipartFormData(
request,
fileUploadHandler
);
multiPartformdata.get("image") !== null
? (imageName = multiPartformdata.get("image").name)
: (imageName = null);
} catch (err) {
errors.image = "Image size too big";
}
if (typeof post !== "string" || post.length > 50 || post.length < 3) {
errors.post = "Post too long or short";
}
if (replying !== "") {
let currentThreadreplyids = [];
const currentThread = await prisma.thread.findUnique({
where: {
id: parseInt(threadId),
},
include: {
posts: true,
},
});
currentThread.posts.map((post) => currentThreadreplyids.push(post.id));
if (
typeof replying !== "string" ||
!parseInt(replying) ||
!currentThreadreplyids.includes(parseInt(replying))
) {
errors.replying = "bad reply id";
}
}
if (Object.keys(errors).length) {
return json(errors, { status: 422 });
}
const createPost = await prisma.post.create({
data: {
comment: post,
imageName: imageName,
replyingTo: replying ? parseInt(replying) : null,
postId: parseInt(threadId),
},
});
return createPost;
}
export async function loader({ params }) {
const threadId = params.threadId;
if (!parseInt(threadId)) throw new Error("Bad route parameter");
const thread = await prisma.thread.findUnique({
where: {
id: parseInt(threadId),
},
include: {
posts: {
include: {
replies: true,
},
},
},
});
if (!thread) {
throw new Error("Thread not found");
}
return thread;
}
export default function Thread() {
const data = useLoaderData();
const actionData = useActionData();
const transition = useTransition();
const [replying, setReplying] = useState();
const revalidator = useRevalidator();
useEffect(() => {
const timer = setInterval(() => {
revalidator.revalidate();
}, 5000);
return () => clearInterval(timer);
}, []);
return (
<Overlay>
<div className="fixed top-0 right-0 m-4 flex flex-col rounded border bg-ctp-crust p-2">
<div className="space-x-4">
<Link
onClick={(event) => {
event.preventDefault();
document.getElementById(`bottom`).scrollIntoView(true);
}}
to={`/threads/${data.id}#bottom`}
>
Bottom
</Link>
<Link
onClick={(event) => {
event.preventDefault();
document.getElementById(`top`).scrollIntoView(true);
}}
to={`/threads/${data.id}#top`}
>
Top
</Link>
</div>
<p>{revalidator.state === "loading" ? "Updating..." : ""}</p>
</div>
<div id="top" className="flex flex-col">
<div
id="OP"
className="flex w-fit flex-col rounded border-4 bg-ctp-crust p-4 shadow-lg"
>
<span>
<strong>{data.title}</strong> Post id: <strong>{data.id}</strong>{" "}
Created at:{" "}
<strong>{new Date(data.createdAt).toLocaleTimeString()}</strong>{" "}
Reply count: <strong>{data.posts.length}</strong>
</span>
<br />
<div className="flex flex-col">
<img
className="max-h-96 w-60"
src={`/uploads/${data.imageName}`}
alt="post image"
/>
<Link
className=" text-xs text-ctp-surface0"
to={`/uploads/${data.imageName}`}
target="_blank"
referrerPolicy="no-referrer"
>
Click to show full image
</Link>
</div>
<p className="">{data.post}</p>
</div>
<ul className="m-8 ">
{data.posts.map((post) => (
<ThreadReply
key={post.id}
setReplying={setReplying}
index={false}
post={post}
/>
))}
</ul>
</div>
<Form
id="bottom"
className="flex flex-col items-center space-y-4"
method="post"
encType="multipart/form-data"
>
<label className="flex flex-col" htmlFor="text-input">
{replying ? (
<p className="text-ctp-black text-center font-semibold">
Replying to reply id: {replying}{" "}
<button
className="text-ctp-sky hover:text-ctp-teal hover:underline"
onClick={() => setReplying("")}
>
Reset
</button>
</p>
) : (
""
)}
<span className="text-center font-semibold text-ctp-subtext0">
Post
</span>{" "}
<textarea
className=" m-1 rounded bg-ctp-surface0 p-1 shadow shadow-ctp-overlay0"
required
minLength={3}
name="post"
type="text"
/>
{actionData ? <p>{actionData.post}</p> : ""}
</label>
<label className="flex flex-col items-center" htmlFor="image-upload">
<span className="text-center font-semibold text-ctp-subtext0">
Image (500kbps max)
</span>
<input
className="m-0 block w-full rounded bg-ctp-surface0 bg-clip-padding px-3 py-1.5 text-base font-semibold text-ctp-text transition ease-in-out"
accept="image/*"
type="file"
id="image-upload"
name="image"
/>
<label htmlFor="anon">
<span>Anonymize filename </span>
<input type="checkbox" name="anonymize" id="anon-check" />
</label>
<p className="text-ctp-red">{actionData?.image}</p>
</label>
<details className="flex items-center justify-center duration-300">
<summary className="cursor-pointer text-center font-semibold text-ctp-subtext0">
Manual replying (needed when JS is disabled)
</summary>
<div class="flex flex-col items-center justify-center">
<input
className="m-1 rounded bg-ctp-surface0 p-1 shadow shadow-ctp-overlay0"
placeholder="Enter a Reply ID"
type="number"
name="replying"
value={replying}
/>
</div>
</details>
<p className="text-ctp-red">{actionData?.replying}</p>
<button
className="rounded-full bg-ctp-crust px-4 py-2 shadow"
type="submit"
>
{transition.state === "submitting" ? "Submitting..." : "Submit"}
</button>
</Form>
</Overlay>
);
}
export function ErrorBoundary({ error }) {
return (
<Overlay>
<div className="w-full rounded bg-ctp-red p-4 text-white">
<h1 className="text-2xl">{error.message}</h1>
<code>{error.stack}</code>
</div>
</Overlay>
);
}