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 (
{
-
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 (
{
{
- logger.log(post.id);
+ onClick={(e) => {
+ handleBookmark(e);
}}
className="flex size-9 items-center justify-center rounded-1 bg-slate-100"
>
-
+
@@ -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) => {
handleVote(e, "upvote")}
className="flex size-8 items-center justify-center rounded-2 bg-slate-100"
>
{
- logger.log("upvoted");
- }}
+ className={`size-5 ${userVote === "upvote" ? "text-orange-500" : "text-slate-900"}`}
/>
-
- {post.num_likes - post.num_dislikes}
-
-
+ {numVotes}
+ handleVote(e, "downvote")}
+ className="flex size-8 items-center justify-center rounded-2 border border-slate-200"
+ >
{
- logger.log("downvoted");
- }}
+ className={`size-5 ${userVote === "downvote" ? "text-purple-800" : "text-slate-900"}`}
/>
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) => (
+
+ {tag.name}
+ removeTag(tag.id)}
+ className="bg-slate-200"
+ aria-label={`Remove ${tag.name}`}
+ >
+ ×
+
+
+ ))}
+
+
+
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(true)}
+ className={`${buttonClass({ intent: "primary", rounded: "full", position: "fixed" })} bottom-8 right-8 size-12`}
+ >
+
+
+
+
+
);
};
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 (
{
);
})}
+
);
diff --git a/client/src/routes/styles.css b/client/src/routes/styles.css
new file mode 100644
index 00000000..2ce5f3bc
--- /dev/null
+++ b/client/src/routes/styles.css
@@ -0,0 +1,55 @@
+.backdrop {
+ background-color: rgb(0 0 0 / 0.1);
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+}
+
+.backdrop:where(.dark, .dark *) {
+ background-color: rgb(0 0 0 / 0.3);
+}
+
+.dialog {
+ position: fixed;
+ inset: var(--inset);
+ z-index: 50;
+ margin: auto;
+ display: flex;
+ height: fit-content;
+ flex-direction: column;
+ gap: 1rem;
+ overflow: visible;
+ border-radius: 0.75rem;
+ background-color: white;
+ padding: 1rem;
+ color: black;
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ --inset: 0.75rem;
+ align-items: flex-start;
+}
+
+@media (min-width: 640px) {
+ .dialog {
+ top: 10vh;
+ bottom: 10vh;
+ margin-top: 0px;
+ width: 420px;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ overflow: visible;
+ }
+}
+
+.dialog:where(.dark, .dark *) {
+ border-width: 1px;
+ border-style: solid;
+ border-color: hsl(204 4% 24%);
+ background-color: hsl(204 4% 16%);
+ color: white;
+}
+
+.heading {
+ margin: 0px;
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+}
diff --git a/client/src/types/post.ts b/client/src/types/post.ts
index 990863e6..a40073cd 100644
--- a/client/src/types/post.ts
+++ b/client/src/types/post.ts
@@ -1,8 +1,25 @@
-import { array, InferInput, number, object, string } from "valibot";
+import {
+ array,
+ boolean,
+ InferInput,
+ literal,
+ nullable,
+ number,
+ object,
+ optional,
+ string,
+ union,
+} from "valibot";
export type Answer = InferInput;
-const postOverviewSchema = object({
+const Tagschema = object({
+ id: string(),
+ name: string(),
+});
+export type ForumResponse = InferInput;
+
+export const postOverviewSchema = object({
id: string(),
title: string(),
description: string(),
@@ -12,18 +29,17 @@ const postOverviewSchema = object({
avatar: string(),
}),
created_at: string(),
- tags: array(
- object({
- id: string(),
- name: string(),
- }),
- ),
+ tags: array(Tagschema),
num_comments: number(),
num_likes: number(),
num_dislikes: number(),
+ userVote: optional(
+ nullable(union([literal("upvote"), literal("downvote")])),
+ ),
+ bookmark: boolean(),
});
-const answerSchema = object({
+export const answerSchema = object({
id: string(),
text: string(),
author: object({
@@ -34,11 +50,17 @@ const answerSchema = object({
created_at: string(),
num_likes: number(),
num_dislikes: number(),
+ userVote: optional(
+ nullable(union([literal("upvote"), literal("downvote")])),
+ ),
});
export const postDetailsSchema = object({
post: postOverviewSchema,
answers: array(answerSchema),
});
-
+export const forumResponseSchema = object({
+ posts: array(postOverviewSchema),
+});
export type PostOverview = InferInput;
export type PostDetails = InferInput;
+export type Tag = InferInput;