From ac8c70af1d39cee20d4b48861af47903ddb0425c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hasan=20Kerem=20=C5=9Eeker?= <95296467+hks1444@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:07:46 +0300 Subject: [PATCH] lab(client): lab6 work (#613) * feat(client): implement create modal * fix(client): syntax error fixed * feat(client): implement create post * feat(client): created vote mock endpoints * feat(client): implement like forum question and its answers * feat(client): implement bookmark * feat(client): make add post aria and keyboard naviagble * feat(cleint): implement answer forum question * style(client): add gap between radio buttons --- client/src/components/button.tsx | 18 +- client/src/components/forum-answer-card.tsx | 75 +++++-- client/src/components/forum-card.tsx | 121 ++++++++---- client/src/components/tagselect.tsx | 200 +++++++++++++++++++ client/src/mocks/handlers.forum.ts | 207 +++++++++++++++++++- client/src/mocks/mocks.forum.ts | 15 ++ client/src/router.tsx | 6 +- client/src/routes/Forum.data.tsx | 54 +++-- client/src/routes/Forum.tsx | 104 +++++++++- client/src/routes/Home.data.tsx | 1 - client/src/routes/Post.data.tsx | 23 ++- client/src/routes/Post.tsx | 33 +++- client/src/routes/styles.css | 55 ++++++ client/src/types/post.ts | 42 +++- 14 files changed, 850 insertions(+), 104 deletions(-) create mode 100644 client/src/components/tagselect.tsx create mode 100644 client/src/routes/styles.css diff --git a/client/src/components/button.tsx b/client/src/components/button.tsx index 89551b42..10347f0a 100644 --- a/client/src/components/button.tsx +++ b/client/src/components/button.tsx @@ -7,7 +7,6 @@ export const buttonInnerRing = cva( "inset-[1px]", "width-full", "height-full", - "rounded-[7px]", "border", "transition-all", ], @@ -24,9 +23,14 @@ export const buttonInnerRing = cva( tertiary: ["border-white/50"], destructive: ["border-red-400"], }, + rounded: { + default: ["rounded-[7px]"], + full: ["rounded-full"], + }, }, defaultVariants: { intent: "primary", + rounded: "default", }, }, ); @@ -38,9 +42,7 @@ export const buttonClass = cva( "flex", "items-center", "justify-center", - "rounded-2", "group", - "relative", "transition-all", "duration-100", "focus-visible:ring-slate-300", @@ -111,11 +113,21 @@ export const buttonClass = cva( small: ["text-xs", "py-1", "px-2"], medium: ["text-sm", "py-2", "px-4"], }, + rounded: { + default: ["rounded-2"], + full: ["rounded-full"], + }, + position: { + fixed: ["fixed"], + relative: ["relative"], + }, }, defaultVariants: { intent: "primary", size: "medium", icon: "none", + rounded: "default", + position: "relative", }, }, ); diff --git a/client/src/components/forum-answer-card.tsx b/client/src/components/forum-answer-card.tsx index 02435a38..46795945 100644 --- a/client/src/components/forum-answer-card.tsx +++ b/client/src/components/forum-answer-card.tsx @@ -1,15 +1,58 @@ import { Button, Separator } from "@ariakit/react"; import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react"; +import { useState } from "react"; +import { useRouteLoaderData } from "react-router-typesafe"; +import { homeLoader } from "../routes/Home.data"; import { Answer } from "../types/post"; -import { getRelativeTime, logger } from "../utils"; +import { BASE_URL, getRelativeTime, logger } from "../utils"; import { Avatar } from "./avatar"; -type forumAnswerCardProps = { +type ForumAnswerCardProps = { answer: Answer; key: string; }; -export const ForumAnswerCard = ({ answer, key }: forumAnswerCardProps) => { +export const ForumAnswerCard = ({ answer, key }: ForumAnswerCardProps) => { + const [userVote, setUserVote] = useState<"upvote" | "downvote" | null>( + answer.userVote || null, + ); + const [numVotes, setNumVotes] = useState( + answer.num_likes - answer.num_dislikes, + ); + const { user, logged_in } = + useRouteLoaderData("home-main"); + const handleVote = async ( + e: React.MouseEvent, + voteType: "upvote" | "downvote", + ) => { + e.preventDefault(); + logger.log("Vote clicked"); + if (!logged_in) return; + + try { + const response = await fetch( + `${BASE_URL}/answers/${answer.id}/vote`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ voteType }), + }, + ); + + if (response.ok) { + const updatedAnswer = await response.json(); + setUserVote( + voteType === updatedAnswer.userVote ? voteType : null, + ); + setNumVotes( + updatedAnswer.num_likes - updatedAnswer.num_dislikes, + ); + } + } catch (error) { + console.error("Vote failed:", error); + } + }; + return (
{
- -

- {answer.num_likes - answer.num_dislikes} +

+ {numVotes}

-
diff --git a/client/src/components/forum-card.tsx b/client/src/components/forum-card.tsx index 1d40ba02..063e6465 100644 --- a/client/src/components/forum-card.tsx +++ b/client/src/components/forum-card.tsx @@ -4,20 +4,81 @@ import { RiArrowUpLine, RiBookmark2Line, } from "@remixicon/react"; +import { useState } from "react"; import { Link } from "react-router-dom"; -import { Post } from "../routes/Forum.data"; -import { logger } from "../utils"; +import { useRouteLoaderData } from "react-router-typesafe"; +import { homeLoader } from "../routes/Home.data"; +import { PostOverview } from "../types/post"; +import { BASE_URL } from "../utils"; import { Avatar } from "./avatar"; -type forumCardProps = { - post: Post; +type ForumCardProps = { + post: PostOverview; key: string; }; -export const ForumCard = ({ post, key }: forumCardProps) => { +export const ForumCard = ({ post, key }: ForumCardProps) => { + const [userVote, setUserVote] = useState<"upvote" | "downvote" | null>( + post.userVote || null, + ); + const [bookmark, setBookmark] = useState(post.bookmark); + const [numVotes, setNumVotes] = useState( + post.num_likes - post.num_dislikes, + ); + const { user, logged_in } = + useRouteLoaderData("home-main"); + const handleVote = async ( + e: React.MouseEvent, + voteType: "upvote" | "downvote", + ) => { + e.preventDefault(); // Prevent link navigation + e.stopPropagation(); // Stop event bubbling + if (!logged_in) return; + + try { + const response = await fetch(`${BASE_URL}/forum/${post.id}/vote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ voteType }), + }); + + if (response.ok) { + const updatedPost = await response.json(); + setUserVote( + voteType === updatedPost.userVote ? voteType : null, + ); + setNumVotes(updatedPost.num_likes - updatedPost.num_dislikes); + } + } catch (error) { + console.error("Vote failed:", error); + } + }; + const handleBookmark = async (e: React.MouseEvent) => { + e.preventDefault(); // Prevent link navigation + e.stopPropagation(); // Stop event bubbling + if (!logged_in) return; + + try { + const response = await fetch( + `${BASE_URL}/forum/${post.id}/bookmark`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + + if (response.ok) { + const updatedPost = await response.json(); + setBookmark(updatedPost.bookmark); + } + } catch (error) { + console.error("Bookmark failed:", error); + } + }; + return ( {

@@ -50,16 +114,11 @@ export const ForumCard = ({ post, key }: forumCardProps) => {

- {post.tags.map(({ name }) => { - return ( - - {name.toLocaleUpperCase()} - - ); - })} + {post.tags.map(({ name }) => ( + + {name.toLocaleUpperCase()} + + ))}
@@ -74,26 +133,22 @@ export const ForumCard = ({ post, key }: forumCardProps) => {
-

- {post.num_likes - post.num_dislikes} -

-
diff --git a/client/src/components/tagselect.tsx b/client/src/components/tagselect.tsx new file mode 100644 index 00000000..5977b429 --- /dev/null +++ b/client/src/components/tagselect.tsx @@ -0,0 +1,200 @@ +export interface AutocompleteTagProps { + initialTags?: Tag[]; + availableTags: Tag[]; + onTagsChange?: (tags: Tag[]) => void; + placeholder?: string; +} + +// AutocompleteTag.tsx +import { Button } from "@ariakit/react"; +import React, { + ChangeEvent, + KeyboardEvent, + RefObject, + useCallback, + useRef, + useState, +} from "react"; +import { Tag } from "../types/post"; +import { inputClass } from "./input"; + +const AutocompleteTag: React.FC = ({ + initialTags = [], + availableTags = [], + onTagsChange, + placeholder = "Type to search for tags...", +}) => { + const [inputValue, setInputValue] = useState(""); + const [selectedTags, setSelectedTags] = useState(initialTags); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + + const inputRef: RefObject = useRef(null); + const optionsRef: RefObject = useRef(null); + + // Filter options based on input value and exclude already selected tags + const filteredOptions: Tag[] = availableTags.filter( + (option: Tag) => + option.name.toLowerCase().includes(inputValue.toLowerCase()) && + !selectedTags.some((tag: Tag) => tag.id === option.id), + ); + + const handleInputChange = (e: ChangeEvent): void => { + setInputValue(e.target.value); + setIsOpen(true); + setHighlightedIndex(-1); + }; + + const handleOptionClick = useCallback( + (option: Tag): void => { + const newTags: Tag[] = [...selectedTags, option]; + setSelectedTags(newTags); + setInputValue(""); + setIsOpen(false); + setHighlightedIndex(-1); + onTagsChange?.(newTags); + inputRef.current?.blur(); + }, + [selectedTags, onTagsChange], + ); + + const removeTag = useCallback( + (tagId: string): void => { + const newTags: Tag[] = selectedTags.filter( + (tag) => tag.id !== tagId, + ); + setSelectedTags(newTags); + onTagsChange?.(newTags); + }, + [selectedTags, onTagsChange], + ); + + const handleKeyDown = (e: KeyboardEvent): void => { + if (!isOpen || filteredOptions.length === 0) return; + + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0, + ); + break; + } + case "ArrowUp": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1, + ); + break; + } + case "Enter": { + e.preventDefault(); + if (highlightedIndex >= 0) { + handleOptionClick(filteredOptions[highlightedIndex]); + } + break; + } + case "Escape": { + e.preventDefault(); + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + case "Tab": { + if (highlightedIndex >= 0) { + e.preventDefault(); + handleOptionClick(filteredOptions[highlightedIndex]); + } + break; + } + } + }; + + // Handle click outside to close dropdown + const handleClickOutside = useCallback((): void => { + setIsOpen(false); + setHighlightedIndex(-1); + }, []); + + return ( +
+ tag.id).join(",")} + /> +
+ {selectedTags.map((tag) => ( + + + ))} +
+
+ setIsOpen(true)} + onBlur={() => { + setTimeout(handleClickOutside, 200); + }} + className={`${inputClass()} w-full`} + placeholder={placeholder} + role="combobox" + aria-expanded={isOpen} + aria-controls="tag-listbox" + aria-activedescendant={ + highlightedIndex >= 0 + ? `option-${filteredOptions[highlightedIndex].id}` + : undefined + } + /> + {isOpen && filteredOptions.length > 0 && ( +
+ {filteredOptions.map((option, index) => ( +
handleOptionClick(option)} + onMouseEnter={() => setHighlightedIndex(index)} + onMouseDown={(e) => e.preventDefault()} + > + {option.name} +
+ ))} +
+ )} +
+
+ ); +}; + +export default AutocompleteTag; diff --git a/client/src/mocks/handlers.forum.ts b/client/src/mocks/handlers.forum.ts index 59a7cea9..fb4cf252 100644 --- a/client/src/mocks/handlers.forum.ts +++ b/client/src/mocks/handlers.forum.ts @@ -1,9 +1,10 @@ import { http, HttpResponse } from "msw"; +import { PostDetails, PostOverview } from "../types/post"; import { BASE_URL } from "../utils"; import { forumDetails, forumOverview } from "./mocks.forum"; export const forumHandlers = [ - http.post(`${BASE_URL}/forum`, async ({ request }) => { + http.get(`${BASE_URL}/forum`, async ({ request }) => { const url = new URL(request.url); const page = Number(url.searchParams.get("page")) || 1; const per_page = Number(url.searchParams.get("per_page")) || 10; @@ -62,4 +63,208 @@ export const forumHandlers = [ return HttpResponse.json(post, { status: 200 }); }), + http.post(`${BASE_URL}/forum`, async ({ request }) => { + const formData = await request.formData(); + const post: PostOverview = { + id: (forumOverview.length + 1).toString(), + title: formData.get("title") as string, + description: formData.get("body") as string, + author: { + full_name: "Current User", // Replace with actual user data from auth + username: "current_user", + avatar: "https://randomuser.me/api/portraits/men/31.jpg", + }, + created_at: new Date().toISOString(), + tags: + formData + .get("tags") + ?.toString() + .split(",") + .map((tag, index) => { + return { name: tag, id: String(index) }; + }) || [], + num_comments: 0, + num_likes: 0, + num_dislikes: 0, + bookmark: false, + }; + const postDetail: PostDetails = { + post, + answers: [], + }; + const postOverview: PostOverview = { + ...post, + }; + forumOverview.unshift(postOverview); + forumDetails.unshift(postDetail); + + return HttpResponse.json({ ...postDetail }, { status: 201 }); + }), + http.post(`${BASE_URL}/forum/:id/vote`, async ({ params, request }) => { + const { id } = params as { id: string }; + const { voteType } = (await request.json()) as { + voteType: "upvote" | "downvote"; + }; + const postIndex = forumOverview.findIndex((post) => post.id === id); + if (postIndex === -1) { + return HttpResponse.json( + { error: "Post not found" }, + { status: 404 }, + ); + } + + // Store vote state in mock database + if (!forumOverview[postIndex].userVote) { + forumOverview[postIndex].userVote = voteType; + if (voteType === "upvote") { + forumOverview[postIndex].num_likes++; + } else { + forumOverview[postIndex].num_dislikes++; + } + } else if (forumOverview[postIndex].userVote !== voteType) { + // Change vote + if (voteType === "upvote") { + forumOverview[postIndex].num_likes++; + forumOverview[postIndex].num_dislikes--; + } else { + forumOverview[postIndex].num_dislikes++; + forumOverview[postIndex].num_likes--; + } + forumOverview[postIndex].userVote = voteType; + } else { + // Cancel vote + if (voteType === "upvote") { + forumOverview[postIndex].num_likes--; + } else { + forumOverview[postIndex].num_dislikes--; + } + forumOverview[postIndex].userVote = null; + } + + return HttpResponse.json(forumOverview[postIndex], { status: 200 }); + }), + http.post(`${BASE_URL}/answers/:id/vote`, async ({ params, request }) => { + const { id } = params; + const { voteType } = (await request.json()) as { + voteType: "upvote" | "downvote"; + }; + + let targetAnswer = null; + let detailIndex = -1; + let answerIndex = -1; + + // Find the answer in forumDetails + for (let i = 0; i < forumDetails.length; i++) { + answerIndex = forumDetails[i].answers.findIndex( + (answer) => answer.id === id, + ); + if (answerIndex !== -1) { + detailIndex = i; + targetAnswer = forumDetails[i].answers[answerIndex]; + break; + } + } + + if (!targetAnswer) { + return HttpResponse.json( + { error: "Answer not found" }, + { status: 404 }, + ); + } + + if (!targetAnswer.userVote) { + targetAnswer.userVote = voteType; + if (voteType === "upvote") { + targetAnswer.num_likes++; + } else { + targetAnswer.num_dislikes++; + } + } else if (targetAnswer.userVote !== voteType) { + if (voteType === "upvote") { + targetAnswer.num_likes++; + targetAnswer.num_dislikes--; + } else { + targetAnswer.num_dislikes++; + targetAnswer.num_likes--; + } + targetAnswer.userVote = voteType; + } else { + if (voteType === "upvote") { + targetAnswer.num_likes--; + } else { + targetAnswer.num_dislikes--; + } + targetAnswer.userVote = null; + } + + forumDetails[detailIndex].answers[answerIndex] = targetAnswer; + return HttpResponse.json(targetAnswer, { status: 200 }); + }), + http.post(`${BASE_URL}/forum/:id/bookmark`, async ({ params }) => { + const { id } = params; + const postIndex = forumOverview.findIndex((post) => post.id === id); + if (postIndex === -1) { + return HttpResponse.json( + { error: "Post not found" }, + { status: 404 }, + ); + } + forumOverview[postIndex].bookmark = !forumOverview[postIndex].bookmark; + return HttpResponse.json(forumOverview[postIndex], { status: 200 }); + }), + http.post( + `${BASE_URL}/forum/:postId/answers`, + async ({ params, request }) => { + const { postId } = params; + const formData = await request.formData(); + const body = formData.get("body"); + if (!body || typeof body !== "string") { + return HttpResponse.json( + { error: "Comment body is required" }, + { status: 400 }, + ); + } + + // Find the post in forumDetails + const detailIndex = forumDetails.findIndex( + (detail) => detail.post.id === postId, + ); + if (detailIndex === -1) { + return HttpResponse.json( + { error: "Post not found" }, + { status: 404 }, + ); + } + + // Create new answer + const newAnswer = { + id: `answer_${Date.now()}`, // Generate unique ID + text: body, + author: { + // This would normally come from the authenticated user + full_name: "Current User", + username: "current_user", + avatar: "https://randomuser.me/api/portraits/men/1.jpg", + }, + created_at: new Date().toISOString(), + num_likes: 0, + num_dislikes: 0, + userVote: null as "upvote" | "downvote" | null, + }; + + // Add answer to the post + forumDetails[detailIndex].answers.unshift(newAnswer); + + // Update comment count in both forumDetails and forumOverview + forumDetails[detailIndex].post.num_comments++; + const postIndex = forumOverview.findIndex( + (post) => post.id === postId, + ); + if (postIndex !== -1) { + forumOverview[postIndex].num_comments++; + } + + return HttpResponse.json(newAnswer, { status: 201 }); + }, + ), ]; diff --git a/client/src/mocks/mocks.forum.ts b/client/src/mocks/mocks.forum.ts index c9ac3a91..b9fa0001 100644 --- a/client/src/mocks/mocks.forum.ts +++ b/client/src/mocks/mocks.forum.ts @@ -19,6 +19,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 15, num_likes: 120, num_dislikes: 8, + bookmark: false, }, { id: "2", @@ -38,6 +39,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 27, num_likes: 95, num_dislikes: 10, + bookmark: false, }, { id: "3", @@ -57,6 +59,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 40, num_likes: 230, num_dislikes: 12, + bookmark: false, }, { id: "4", @@ -76,6 +79,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 30, num_likes: 180, num_dislikes: 14, + bookmark: false, }, { id: "5", @@ -95,6 +99,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 22, num_likes: 150, num_dislikes: 7, + bookmark: false, }, { id: "6", @@ -114,6 +119,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 18, num_likes: 98, num_dislikes: 5, + bookmark: false, }, { id: "7", @@ -133,6 +139,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 12, num_likes: 85, num_dislikes: 3, + bookmark: false, }, { id: "8", @@ -152,6 +159,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 14, num_likes: 75, num_dislikes: 4, + bookmark: false, }, { id: "9", @@ -171,6 +179,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 23, num_likes: 140, num_dislikes: 9, + bookmark: false, }, { id: "10", @@ -190,6 +199,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 35, num_likes: 190, num_dislikes: 11, + bookmark: false, }, { id: "11", @@ -209,6 +219,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 20, num_likes: 110, num_dislikes: 6, + bookmark: false, }, { id: "12", @@ -228,6 +239,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 18, num_likes: 105, num_dislikes: 5, + bookmark: false, }, { id: "13", @@ -247,6 +259,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 29, num_likes: 130, num_dislikes: 7, + bookmark: false, }, { id: "14", @@ -266,6 +279,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 32, num_likes: 210, num_dislikes: 13, + bookmark: false, }, { id: "15", @@ -285,6 +299,7 @@ export const forumOverview: PostOverview[] = [ num_comments: 22, num_likes: 140, num_dislikes: 6, + bookmark: false, }, // ... additional posts up to 30 ]; diff --git a/client/src/router.tsx b/client/src/router.tsx index e355113f..932f6b46 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -2,7 +2,7 @@ import { RouteObject } from "react-router-dom"; import { ErrorPage } from "./routes/_error"; import { Root } from "./routes/_root"; import { Forum } from "./routes/Forum"; -import { forumLoader } from "./routes/Forum.data"; +import { createPostAction, forumLoader } from "./routes/Forum.data"; import { Home } from "./routes/Home"; import { homeLoader } from "./routes/Home.data"; import { HomeMain } from "./routes/Home.main"; @@ -12,7 +12,7 @@ import { Login } from "./routes/Login"; import { loginAction, loginLoader } from "./routes/Login.data"; import { logoutLoader } from "./routes/Logout.data"; import { PostPage } from "./routes/Post"; -import { postLoader } from "./routes/Post.data"; +import { postAction, postLoader } from "./routes/Post.data"; import { QuizPage } from "./routes/Quiz"; import { quizLoader } from "./routes/Quiz.data"; import { Quizzes } from "./routes/Quizzes"; @@ -46,11 +46,13 @@ export const routes: RouteObject[] = [ path: "forum", element: , loader: forumLoader, + action: createPostAction, }, { path: "forum/:postId", element: , loader: postLoader, + action: postAction, }, { path: "quizzes", diff --git a/client/src/routes/Forum.data.tsx b/client/src/routes/Forum.data.tsx index 444c7ba4..8408dc4b 100644 --- a/client/src/routes/Forum.data.tsx +++ b/client/src/routes/Forum.data.tsx @@ -1,33 +1,8 @@ import { LoaderFunction } from "react-router"; -import { array, InferInput, number, object, safeParse, string } from "valibot"; -import { BASE_URL } from "../utils"; - -export type Post = InferInput; - -const postSchema = object({ - id: string(), - title: string(), - description: string(), - author: object({ - full_name: string(), - username: string(), - avatar: string(), - }), - created_at: string(), - tags: array( - object({ - id: string(), - name: string(), - }), - ), - num_comments: number(), - num_likes: number(), - num_dislikes: number(), -}); - -const forumResponseSchema = object({ - posts: array(postSchema), -}); +import { redirect } from "react-router-typesafe"; +import { safeParse } from "valibot"; +import { forumResponseSchema, postDetailsSchema } from "../types/post"; +import { BASE_URL, logger } from "../utils"; export const forumLoader = (async ({ request }) => { const url = new URL(request.url); @@ -36,7 +11,7 @@ export const forumLoader = (async ({ request }) => { const res = await fetch( `${BASE_URL}/forum/?page=${page}&per_page=${per_page}`, { - method: "POST", + method: "GET", headers: { "Content-Type": "application/json", }, @@ -49,3 +24,22 @@ export const forumLoader = (async ({ request }) => { } return output; }) satisfies LoaderFunction; + +export const createPostAction = async ({ request }: { request: Request }) => { + const formData = await request.formData(); + const res = await fetch(`${BASE_URL}/forum`, { + method: "POST", + body: formData, + }); + if (!res.ok) { + throw new Error(`Failed to create post`); + } + + const data = await res.json(); + const { output, issues, success } = safeParse(postDetailsSchema, data); + if (!success) { + logger.log(data); + throw new Error(`Failed to create post: ${issues}`); + } + return redirect("/forum"); +}; diff --git a/client/src/routes/Forum.tsx b/client/src/routes/Forum.tsx index e21bc5e5..5fce4944 100644 --- a/client/src/routes/Forum.tsx +++ b/client/src/routes/Forum.tsx @@ -1,17 +1,42 @@ -import { useLoaderData, useRouteLoaderData } from "react-router-typesafe"; +import { Button, Dialog, DialogHeading } from "@ariakit/react"; +import { RiAddLine } from "@remixicon/react"; +import { useState } from "react"; +import { Form } from "react-router-dom"; +import { + useActionData, + useLoaderData, + useRouteLoaderData, +} from "react-router-typesafe"; +import { buttonClass, buttonInnerRing } from "../components/button"; import { ForumCard } from "../components/forum-card"; +import { inputClass } from "../components/input"; import { PageHead } from "../components/page-head"; -import { forumLoader } from "./Forum.data"; +import AutocompleteTag from "../components/tagselect"; +import { Tag } from "../types/post"; +import { createPostAction, forumLoader } from "./Forum.data"; import { homeLoader } from "./Home.data"; +import "./styles.css"; + +const availableTags: Tag[] = [ + { id: "Writing", name: "Writing" }, + { id: "Grammar", name: "Grammar" }, + { id: "Word", name: "Word" }, + { id: "Vocabulary", name: "Vocabulary" }, + { id: "English", name: "English" }, + // Add more tags as needed +]; + export const Forum = () => { + const actionData = useActionData(); + const [creatingPost, setCreatingPost] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); const data = useLoaderData(); const { user, logged_in } = useRouteLoaderData("home-main"); const description = logged_in ? `This is your time to shine ${user.full_name}` : "Test your knowledge of various topics. Log in to track your progress."; - return (
@@ -22,6 +47,79 @@ export const Forum = () => { ))}
+ + setCreatingPost(false)} + backdrop={
} + className="dialog" + > + + Create New Question + +
+
+ + + +
+
+ Tags: +
+ + +
+ +
+
+
); }; diff --git a/client/src/routes/Home.data.tsx b/client/src/routes/Home.data.tsx index fc80989e..6c3a7810 100644 --- a/client/src/routes/Home.data.tsx +++ b/client/src/routes/Home.data.tsx @@ -6,7 +6,6 @@ import { userSchema } from "../types/user"; export const homeLoader = () => { const user = sessionStorage.getObject(USER) || localStorage.getObject(USER); const { output, success } = safeParse(userSchema, user); - if (!success) { useToastStore.getState().add({ id: "not-logged-in", diff --git a/client/src/routes/Post.data.tsx b/client/src/routes/Post.data.tsx index 7adfe781..d5cc4301 100644 --- a/client/src/routes/Post.data.tsx +++ b/client/src/routes/Post.data.tsx @@ -1,6 +1,6 @@ -import { LoaderFunction } from "react-router"; +import { ActionFunctionArgs, LoaderFunction, redirect } from "react-router"; import { safeParse } from "valibot"; -import { postDetailsSchema } from "../types/post"; +import { answerSchema, postDetailsSchema } from "../types/post"; import { BASE_URL } from "../utils"; export const postLoader = (async ({ params, request }) => { @@ -36,3 +36,22 @@ export const postLoader = (async ({ params, request }) => { return output; }) satisfies LoaderFunction; + +export const postAction = async ({ params, request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const postId = params.postId; + const res = await fetch(`${BASE_URL}/forum/${postId}/answers`, { + method: "POST", + body: formData, + }); + if (!res.ok) { + throw new Error(`Failed to answer post`); + } + + const data = await res.json(); + const { output, issues, success } = safeParse(answerSchema, data); + if (!success) { + throw new Error(`Failed to create post: ${issues}`); + } + return redirect(`/forum/${postId}`); +}; diff --git a/client/src/routes/Post.tsx b/client/src/routes/Post.tsx index 1211ffe4..e5edd879 100644 --- a/client/src/routes/Post.tsx +++ b/client/src/routes/Post.tsx @@ -1,8 +1,10 @@ import { Radio, RadioGroup, RadioProvider, useFormStore } from "@ariakit/react"; -import { useSearchParams } from "react-router-dom"; +import { Form, useSearchParams } from "react-router-dom"; import { useLoaderData, useRouteLoaderData } from "react-router-typesafe"; +import { buttonClass } from "../components/button"; import { ForumAnswerCard } from "../components/forum-answer-card"; import { ForumCard } from "../components/forum-card"; +import { inputClass } from "../components/input"; import { homeLoader } from "./Home.data"; import { postLoader } from "./Post.data"; @@ -29,10 +31,10 @@ export const PostPage = () => { console.log(description); return (
-
+
- + -
+
{data.answers.map((answer) => { return ( { ); })}
+