added community, CRUD
This commit is contained in:
@@ -4,6 +4,10 @@ import SignUpPage from "./pages/SignUpPage";
|
|||||||
import SignInPage from "./pages/SignInPage";
|
import SignInPage from "./pages/SignInPage";
|
||||||
import SpringboardPage from "./pages/SpringboardPage";
|
import SpringboardPage from "./pages/SpringboardPage";
|
||||||
import ManageUserAccountPage from "./pages/ManageUserAccountPage";
|
import ManageUserAccountPage from "./pages/ManageUserAccountPage";
|
||||||
|
|
||||||
|
import CommunityPage from "./pages/CommunityPage";
|
||||||
|
import CreatePostPage from "./pages/CreatePostPage";
|
||||||
|
import EditPostPage from "./pages/EditPostPage";
|
||||||
import SchedulePage from "./pages/SchedulePage";
|
import SchedulePage from "./pages/SchedulePage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -17,6 +21,10 @@ function App() {
|
|||||||
element={<ManageUserAccountPage />}
|
element={<ManageUserAccountPage />}
|
||||||
path="/manage-account/:accessToken"
|
path="/manage-account/:accessToken"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route element={<CommunityPage />} path="/community" />
|
||||||
|
<Route element={<CreatePostPage />} path="/createPost" />
|
||||||
|
<Route element={<EditPostPage/>} path="/editPost/:id" />
|
||||||
<Route element={<SchedulePage/>} path="/schedule"/>
|
<Route element={<SchedulePage/>} path="/schedule"/>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
28
client/src/components/NextUIFormikTextarea.tsx
Normal file
28
client/src/components/NextUIFormikTextarea.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Textarea } from "@nextui-org/react";
|
||||||
|
import { useField } from "formik";
|
||||||
|
|
||||||
|
interface NextUIFormikTextareaProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
placeholder: string;
|
||||||
|
labelPlacement?: "inside" | "outside";
|
||||||
|
}
|
||||||
|
|
||||||
|
const NextUIFormikTextarea = ({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: NextUIFormikTextareaProps) => {
|
||||||
|
const [field, meta] = useField(props.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
{...props}
|
||||||
|
label={label}
|
||||||
|
isInvalid={meta.touched && !!meta.error}
|
||||||
|
errorMessage={meta.touched && meta.error ? meta.error : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NextUIFormikTextarea;
|
||||||
278
client/src/pages/CommunityPage.tsx
Normal file
278
client/src/pages/CommunityPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// import { title } from "@/components/primitives";
|
||||||
|
import DefaultLayout from "../layouts/default";
|
||||||
|
import { SetStateAction, useEffect, useState } from 'react';
|
||||||
|
import { Button, Avatar, Link, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Input } from "@nextui-org/react";
|
||||||
|
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@nextui-org/react";
|
||||||
|
import axios from "axios";
|
||||||
|
import config from "../config";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
|
||||||
|
|
||||||
|
// communityList is a state variable
|
||||||
|
// function setCommunityList is the setter function for the state variable
|
||||||
|
// e initial value of the state variable is an empty array []
|
||||||
|
// After getting the api response, call setCommunityList() to set the value of CommunityList
|
||||||
|
const [communityList, setCommunityList] = useState<Post[]>([]);
|
||||||
|
|
||||||
|
// Search Function
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const onSearchChange = (e: { target: { value: SetStateAction<string>; }; }) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPosts = () => {
|
||||||
|
axios
|
||||||
|
.get(config.serverAddress + '/post').then((res) => {
|
||||||
|
setCommunityList(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchPosts = () => {
|
||||||
|
axios
|
||||||
|
.get(config.serverAddress + `/post?search=${search}`).then((res) => {
|
||||||
|
setCommunityList(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSearchKeyDown = (e: { key: string; }) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
searchPosts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickSearch = () => {
|
||||||
|
searchPosts();
|
||||||
|
}
|
||||||
|
const onClickClear = () => {
|
||||||
|
setSearch('');
|
||||||
|
getPosts();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios
|
||||||
|
.get(config.serverAddress + '/post').then((res) => {
|
||||||
|
console.log(res.data);
|
||||||
|
setCommunityList(res.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteClick = (post: Post) => {
|
||||||
|
setSelectedPost(post);
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (selectedPost) {
|
||||||
|
try {
|
||||||
|
await axios
|
||||||
|
.delete(config.serverAddress + `/post/${selectedPost.id}`);
|
||||||
|
setCommunityList((prevList) => prevList.filter(post => post.id !== selectedPost.id));
|
||||||
|
onOpenChange();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting post:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<section className="flex flex-row m-10">
|
||||||
|
<div className="flex flex-col w-4/5">
|
||||||
|
{
|
||||||
|
communityList.map((post) => {
|
||||||
|
return (
|
||||||
|
<section className="mb-7 flex flex-row bg-red-50 border border-none rounded-2xl">
|
||||||
|
<div className="pl-7 pr-3 pt-5">
|
||||||
|
<Avatar src="https://pbs.twimg.com/media/GOva9x5a0AAK8Bn?format=jpg&name=large" size="lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center w-full">
|
||||||
|
<div className="h-full flex flex-col justify-center py-5">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div className="flex flex-col justify-center w-11/12">
|
||||||
|
<div className="h-full text-sm text-neutral-500">
|
||||||
|
<p>Adam</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<p className="text-lg mb-2 font-bold">
|
||||||
|
{post.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row-reverse justify-center items-center h-full w-10">
|
||||||
|
<Dropdown>
|
||||||
|
<div>
|
||||||
|
<DropdownTrigger className="justify-center items-center">
|
||||||
|
<Button isIconOnly className="w-full h-3/5 justify-center items-center" variant="light">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-6">
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu aria-label="Static Actions">
|
||||||
|
<DropdownItem
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to editPost page with post.id
|
||||||
|
const editPostUrl = `/editPost/${post.id}`; // Replace with your actual edit post route
|
||||||
|
window.location.href = editPostUrl;
|
||||||
|
}}>
|
||||||
|
Edit
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem key="delete" className="text-danger" color="danger" onClick={() => handleDeleteClick(post)}>
|
||||||
|
Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="">
|
||||||
|
{post.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div>
|
||||||
|
<p>tag1</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>tag2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="p-5">
|
||||||
|
<p>Like</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<p>Comment</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<p>...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<section className="flex flex-col w-1/5 mx-auto h-screen">
|
||||||
|
<Link href="/createPost" className="w-11/12 h-1/5 mx-auto mb-2">
|
||||||
|
<Button isIconOnly className="w-full h-full bg-red-color text-white">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row justify-center items-center gap-2">
|
||||||
|
<div className="text-xl">
|
||||||
|
<p>Create a post!</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-5">
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col text-xs justify-center items-center">
|
||||||
|
<p>Socialize, share your experience or <br></br>ask a question!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-row w-11/12 border border-none rounded-2xl mx-auto bg-gray-100">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
placeholder="Search Title/Content"
|
||||||
|
onChange={onSearchChange}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
/>
|
||||||
|
<Button isIconOnly className="bg-red-color">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-6 bg-red-color text-white"
|
||||||
|
onClick={onClickSearch}>
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button isIconOnly className="bg-red-color">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-6 text-white"
|
||||||
|
onClick={onClickClear}>
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">Confirm Delete</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>Are you sure you want to delete this post?</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" onPress={handleDeleteConfirm}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
client/src/pages/CreatePostPage.tsx
Normal file
127
client/src/pages/CreatePostPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import DefaultLayout from '../layouts/default';
|
||||||
|
import { Button, Link } from "@nextui-org/react";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import axios from "axios";
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import NextUIFormikInput from '../components/NextUIFormikInput';
|
||||||
|
import NextUIFormikTextarea from '../components/NextUIFormikTextarea';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
title: Yup.string().trim()
|
||||||
|
.min(3, 'Title must be at least 3 characters')
|
||||||
|
.max(200, 'Title must be at most 200 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9\s]+$/, "Title can only contain letters, numbers, and spaces")
|
||||||
|
.required('Title is required'),
|
||||||
|
content: Yup.string().trim()
|
||||||
|
.min(3, 'Content must be at least 3 characters')
|
||||||
|
.max(500, 'Content must be at most 500 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9,\s!"'-]*$/, 'Only letters, numbers, commas, spaces, exclamation marks, quotations, and common symbols are allowed')
|
||||||
|
.required('Content is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
function CreatePostPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
tags: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any, { setSubmitting, resetForm , setFieldError }: any) => {
|
||||||
|
try {
|
||||||
|
const response = await axios
|
||||||
|
.post(config.serverAddress + '/post', values); // Assuming an API route
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log('Post created successfully:', response.data);
|
||||||
|
resetForm(); // Clear form after successful submit
|
||||||
|
navigate("/community");
|
||||||
|
} else {
|
||||||
|
console.error('Error creating post:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response && error.response.data && error.response.data.field) {
|
||||||
|
setFieldError(error.response.data.field, error.response.data.error);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<section className="w-8/12 mx-auto">
|
||||||
|
<Link href="/community">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="black"
|
||||||
|
className="size-5">
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
<section className="w-8/12 mx-auto p-5 bg-red-100 border border-none rounded-2xl">
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({ isValid, dirty, isSubmitting }) => (
|
||||||
|
<Form className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<NextUIFormikInput
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your post title"
|
||||||
|
labelPlacement="inside"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p>Image</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NextUIFormikTextarea
|
||||||
|
label="Content"
|
||||||
|
name="content"
|
||||||
|
placeholder="Write your post content here"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NextUIFormikInput
|
||||||
|
label="Tags (Optional)"
|
||||||
|
name="tags"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter tags"
|
||||||
|
labelPlacement="inside"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row-reverse border">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-red-color text-white text-xl w-1/6"
|
||||||
|
disabled={!isValid || !dirty || isSubmitting}
|
||||||
|
>
|
||||||
|
<p>Post</p>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</section>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreatePostPage;
|
||||||
146
client/src/pages/EditPostPage.tsx
Normal file
146
client/src/pages/EditPostPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import DefaultLayout from '../layouts/default';
|
||||||
|
import { Button, Link } from "@nextui-org/react";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import NextUIFormikInput from '../components/NextUIFormikInput';
|
||||||
|
import NextUIFormikTextarea from '../components/NextUIFormikTextarea';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
title: Yup.string().trim()
|
||||||
|
.min(3, 'Title must be at least 3 characters')
|
||||||
|
.max(200, 'Title must be at most 200 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9\s]+$/, "Title can only contain letters, numbers, and spaces")
|
||||||
|
.required('Title is required'),
|
||||||
|
content: Yup.string().trim()
|
||||||
|
.min(3, 'Content must be at least 3 characters')
|
||||||
|
.max(500, 'Content must be at most 500 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9,\s!"'-]*$/, 'Only letters, numbers, commas, spaces, exclamation marks, quotations, and common symbols are allowed')
|
||||||
|
.required('Content is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
function editPost() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// const initialValues = {
|
||||||
|
// title: '',
|
||||||
|
// content: '',
|
||||||
|
// tags: ''
|
||||||
|
// };
|
||||||
|
|
||||||
|
const [post, setPost] = useState({
|
||||||
|
title: "",
|
||||||
|
content: ""
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get(config.serverAddress + `/post/${id}`).then((res) => {
|
||||||
|
setPost(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any, { setSubmitting, resetForm , setFieldError }: any) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(config.serverAddress + `/post/${id}`, values); // Assuming an API route
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log('Post updated successfully:', response.data);
|
||||||
|
resetForm(); // Clear form after successful submit
|
||||||
|
navigate("/community");
|
||||||
|
} else {
|
||||||
|
console.error('Error updating post:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response && error.response.data && error.response.data.field) {
|
||||||
|
setFieldError(error.response.data.field, error.response.data.error);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<section className="w-8/12 mx-auto">
|
||||||
|
<Link href="/community">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="black"
|
||||||
|
className="size-5">
|
||||||
|
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
<section className="w-8/12 mx-auto p-5 bg-red-100 border border-none rounded-2xl">
|
||||||
|
{
|
||||||
|
!loading && (
|
||||||
|
<Formik
|
||||||
|
initialValues={post}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({ isValid, dirty, isSubmitting }) => (
|
||||||
|
<Form className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<NextUIFormikInput
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your post title"
|
||||||
|
labelPlacement="inside"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p>Image</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NextUIFormikTextarea
|
||||||
|
label="Content"
|
||||||
|
name="content"
|
||||||
|
placeholder="Write your post content here"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NextUIFormikInput
|
||||||
|
label="Tags (Optional)"
|
||||||
|
name="tags"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter tags"
|
||||||
|
labelPlacement="inside"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row-reverse border">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-red-color text-white text-xl w-1/6"
|
||||||
|
disabled={!isValid || !dirty || isSubmitting}
|
||||||
|
>
|
||||||
|
<p>Update</p>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default editPost
|
||||||
@@ -8,7 +8,11 @@ export default {
|
|||||||
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
|
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'red-color': '#F36E6E', // Your color code
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
plugins: [nextui()],
|
plugins: [nextui()],
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ app.get("/", (req, res) => {
|
|||||||
|
|
||||||
app.use("/users", usersRoute);
|
app.use("/users", usersRoute);
|
||||||
|
|
||||||
|
const postRoute = require('./routes/post');
|
||||||
|
app.use("/post", postRoute);
|
||||||
|
|
||||||
db.sequelize
|
db.sequelize
|
||||||
.sync({ alter: true })
|
.sync({ alter: true })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
15
server/models/Post.js
Normal file
15
server/models/Post.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Post = sequelize.define("Post", {
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'posts'
|
||||||
|
});
|
||||||
|
return Post;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"author": "Wind_Explorer",
|
"author": "Wind_Explorer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.40.3",
|
"argon2": "^0.40.3",
|
||||||
|
"bad-words": "^3.0.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
1803
server/pnpm-lock.yaml
generated
1803
server/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
155
server/routes/post.js
Normal file
155
server/routes/post.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Post } = require('../models');
|
||||||
|
const { Op, where } = require("sequelize");
|
||||||
|
const yup = require("yup");
|
||||||
|
|
||||||
|
// Profanity function
|
||||||
|
const Filter = require('bad-words'); // Import the bad-words library
|
||||||
|
const filter = new Filter();
|
||||||
|
|
||||||
|
var newBadWords = ['bloody', 'bitch', 'fucker', 'fuck', 'fk', 'shit', 'bastard', 'dumbass', 'stupid', 'hell'];
|
||||||
|
filter.addWords(...newBadWords);
|
||||||
|
|
||||||
|
let removeWords = [''];
|
||||||
|
filter.removeWords(...removeWords);
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
let data = req.body;
|
||||||
|
// Validate request body
|
||||||
|
let validationSchema = yup.object({
|
||||||
|
title: yup.string().trim().min(3).max(200).required(), // yup object to define validation schema
|
||||||
|
content: yup.string().trim().min(3).max(500).required()
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
data = await validationSchema.validate(data, // validate() method is used to validate data against the schema and returns the valid data and any applied transformations
|
||||||
|
{ abortEarly: false }); // abortEarly: false means the validation won’t stop when the first error is detected
|
||||||
|
// Process valid data
|
||||||
|
|
||||||
|
// Check for profanity
|
||||||
|
if (filter.isProfane(data.title)) {
|
||||||
|
return res.status(400).json({ field: 'title', error: 'Profane content detected in title' });
|
||||||
|
}
|
||||||
|
if (filter.isProfane(data.content)) {
|
||||||
|
return res.status(400).json({ field: 'content', error: 'Profane content detected in content' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await Post.create(data); // sequelize method create() is used to insert data into the database table
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(400).json({ errors: err.errors }); // If the error is caught, return the bad request
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// // sequelize method findAll is used to generate a standard SELECT query which will retrieve all entries from the table
|
||||||
|
// router.get("/", async (req, res) => {
|
||||||
|
// let list = await Tutorial.findAll({
|
||||||
|
// // order option takes an array of items. These items are themselves in the form of [column, direction]
|
||||||
|
// order: [['createdAt', 'DESC']]
|
||||||
|
// });
|
||||||
|
// res.json(list);
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
let condition = {};
|
||||||
|
let search = req.query.search;
|
||||||
|
if (search) {
|
||||||
|
condition[Op.or] = [
|
||||||
|
{ title: { [Op.like]: `%${search}%` } },
|
||||||
|
{ content: { [Op.like]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// You can add condition for other columns here
|
||||||
|
// e.g. condition.columnName = value;
|
||||||
|
|
||||||
|
let list = await Post.findAll({
|
||||||
|
where: condition,
|
||||||
|
// order option takes an array of items. These items are themselves in the form of [column, direction]
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
res.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id", async (req, res) => {
|
||||||
|
let id = req.params.id;
|
||||||
|
let post = await Post.findByPk(id);
|
||||||
|
// Check id not found
|
||||||
|
if (!post) {
|
||||||
|
res.sendStatus(404); // If the tutorial is null, return error code 404 for Not Found
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(post);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:id", async (req, res) => {
|
||||||
|
let id = req.params.id;
|
||||||
|
// Check id not found
|
||||||
|
let post = await Post.findByPk(id);
|
||||||
|
if (!post) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = req.body;
|
||||||
|
// Validate request body
|
||||||
|
let validationSchema = yup.object({
|
||||||
|
title: yup.string().trim().min(3).max(100),
|
||||||
|
content: yup.string().trim().min(3).max(500)
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
data = await validationSchema.validate(data,
|
||||||
|
{ abortEarly: false });
|
||||||
|
|
||||||
|
// Check for profanity
|
||||||
|
if (filter.isProfane(data.title)) {
|
||||||
|
return res.status(400).json({ field: 'title', error: 'Profane content detected in title' });
|
||||||
|
}
|
||||||
|
if (filter.isProfane(data.content)) {
|
||||||
|
return res.status(400).json({ field: 'content', error: 'Profane content detected in content' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process valid data
|
||||||
|
let post = await Post.update(data, { // update() updates data based on the where condition, and returns the number of rows affected
|
||||||
|
where: { id: id } // If num equals 1, return OK, otherwise return Bad Request
|
||||||
|
});
|
||||||
|
if (post == 1) {
|
||||||
|
res.json({
|
||||||
|
message: "Post was updated successfully."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(400).json({
|
||||||
|
message: `Cannot update post with id ${id}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(400).json({ errors: err.errors });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", async (req, res) => {
|
||||||
|
let id = req.params.id;
|
||||||
|
// Check id not found
|
||||||
|
let post = await Post.findByPk(id);
|
||||||
|
if (!post) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let num = await Post.destroy({ // destroy() deletes data based on the where condition, and returns the number of rows affected
|
||||||
|
where: { id: id }
|
||||||
|
})
|
||||||
|
if (num == 1) { // destry() returns no. of rows affected, that's why if num == 1
|
||||||
|
res.json({
|
||||||
|
message: "Post was deleted successfully."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(400).json({
|
||||||
|
message: `Cannot delete post with id ${id}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user