diff --git a/frontend/package.json b/frontend/package.json index 677a9431..687a520e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -33,6 +34,8 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.1.1", + "@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.3", "@tailwindcss/typography": "^0.5.15", "@tanstack/react-query": "^5.35.1", diff --git a/frontend/src/components/Answers.tsx b/frontend/src/components/Answers.tsx index fc9e42e7..5c490211 100644 --- a/frontend/src/components/Answers.tsx +++ b/frontend/src/components/Answers.tsx @@ -69,6 +69,13 @@ export function Answers({ questionId }: AnswersProps) { onDownvote={() => handleVote(answer.id, -1)} /> ))} + {answers.length === 0 && ( + + {" "} + This question doesn't have an answer yet. Contribute to the discussion + by answering this question. + + )} ); } diff --git a/frontend/src/components/DifficultyBar.tsx b/frontend/src/components/DifficultyBar.tsx index 80f6125b..e4f5007a 100644 --- a/frontend/src/components/DifficultyBar.tsx +++ b/frontend/src/components/DifficultyBar.tsx @@ -1,161 +1,187 @@ -import React, {useState} from "react"; -import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"; -import { useRateQuestion} from "@/services/api/programmingForumComponents"; -import { Button } from "@/components/ui/button"; -import useAuthStore from "@/services/auth"; - - -type DifficultyBarProps = { - easyCount: number; - mediumCount: number; - hardCount: number; - questionId: number; -}; - -export const DifficultyBar: React.FC = ({ - easyCount, - mediumCount, - hardCount, - questionId, -}) => { - const { token } = useAuthStore(); // Fetch authentication status - const [votedDifficulty, setVotedDifficulty] = useState(null); // Track user's vote - const [localCounts, setLocalCounts] = useState({ - easy: easyCount, - medium: mediumCount, - hard: hardCount, - }); - - const { mutateAsync: rateQuestion } = useRateQuestion(); - - // Helper to calculate percentage - const calculatePercentage = (count: number) => - totalVotes > 0 ? ((count / totalVotes) * 100).toFixed(1) : "0"; - - const handleVote = async (difficulty: "EASY" | "MEDIUM" | "HARD") => { - if (!token) { - alert("You must be logged in to vote!"); - return; - } - - try { - const response = await rateQuestion({ - pathParams: { id: questionId }, - body: { difficulty: difficulty }, - }); - - const { easyCount, mediumCount, hardCount } = response.data; - - setLocalCounts({ - easy: easyCount ?? 0, - medium: mediumCount ?? 0, - hard: hardCount ?? 0, - }); - - setVotedDifficulty(difficulty); - } catch (error) { - console.error("Failed to vote:", error); - alert("There was an issue submitting your vote. Please try again."); - } - }; - - const totalVotes = localCounts.easy + localCounts.medium + localCounts.hard; - - const getHighestVotedDifficulty = () => { - const counts = [ - { level: "Easy", count: localCounts.easy }, - { level: "Medium", count: localCounts.medium }, - { level: "Hard", count: localCounts.hard }, - ]; - const highest = counts.reduce((prev, current) => //any equality -> pick higher difficulty level - prev.count > current.count ? prev : current - ); - return highest.level; - }; - - return ( -
- - {/* Voting Form */} -
-

Vote for Difficulty Level:

-
- - - -
-
- - {/* Difficulty Bar */} -
-

- The community finds this question: {getHighestVotedDifficulty()} difficulty. -

-
- Easy: {localCounts.easy} votes,{" "} - Medium: {localCounts.medium} votes,{" "} - Hard: {localCounts.hard} votes -
-
- {/* Easy Section */} - - -
-
- - {localCounts.easy} Easy votes - -
- - {/* Medium Section */} - - -
-
- - {localCounts.medium} Medium votes - -
- - {/* Hard Section */} - - -
-
- - {localCounts.hard} Hard votes - -
-
-
-
-
- ); -}; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useRateQuestion } from "@/services/api/programmingForumComponents"; +import useAuthStore from "@/services/auth"; +import React, { useState } from "react"; +import { Card } from "./ui/card"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; + +type DifficultyBarProps = { + easyCount: number; + mediumCount: number; + hardCount: number; + questionId: number; +}; + +export const DifficultyBar: React.FC = ({ + easyCount, + mediumCount, + hardCount, + questionId, +}) => { + const { token } = useAuthStore(); // Fetch authentication status + const [votedDifficulty, setVotedDifficulty] = useState(null); // Track user's vote + const [localCounts, setLocalCounts] = useState({ + easy: easyCount, + medium: mediumCount, + hard: hardCount, + }); + + const { mutateAsync: rateQuestion } = useRateQuestion(); + + // Helper to calculate percentage + const calculatePercentage = (count: number) => + totalVotes > 0 ? ((count / totalVotes) * 100).toFixed(1) : "0"; + + const handleVote = async (difficulty: "EASY" | "MEDIUM" | "HARD") => { + if (!token) { + alert("You must be logged in to vote!"); + return; + } + + try { + const response = await rateQuestion({ + pathParams: { id: questionId }, + body: { difficulty: difficulty }, + }); + + const { easyCount, mediumCount, hardCount } = response.data; + + setLocalCounts({ + easy: easyCount ?? 0, + medium: mediumCount ?? 0, + hard: hardCount ?? 0, + }); + + setVotedDifficulty(difficulty); + } catch (error) { + console.error("Failed to vote:", error); + alert("There was an issue submitting your vote. Please try again."); + } + }; + + const totalVotes = localCounts.easy + localCounts.medium + localCounts.hard; + + const getHighestVotedDifficulty = () => { + const counts = [ + { level: "Easy", count: localCounts.easy }, + { level: "Medium", count: localCounts.medium }, + { level: "Hard", count: localCounts.hard }, + ]; + const highest = counts.reduce( + ( + prev, + current, //any equality -> pick higher difficulty level + ) => (prev.count > current.count ? prev : current), + ); + return highest.level; + }; + + return ( + +

Difficulty voting

+ + {/* Voting Form */} +
+ Vote for Difficulty Level: + + val && handleVote(val as "EASY" | "MEDIUM" | "HARD") + } + type="single" + size="lg" + variant="outline" + > + + Easy + + + Medium + + + Hard + + +
+ + {/* Difficulty Bar */} +
+

+ The community finds this question:{" "} + {getHighestVotedDifficulty()} difficulty. +

+
+ Easy: {localCounts.easy} votes,{" "} + Medium: {localCounts.medium} votes,{" "} + Hard: {localCounts.hard} votes +
+
+ {/* Easy Section */} + + +
+
+ {localCounts.easy} Easy votes +
+ + {/* Medium Section */} + + +
+
+ {localCounts.medium} Medium votes +
+ + {/* Hard Section */} + + +
+
+ {localCounts.hard} Hard votes +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/ui/collapsible.tsx b/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..5c28cbcc --- /dev/null +++ b/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 00000000..35ba3feb --- /dev/null +++ b/frontend/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import { type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { toggleVariants } from "@/components/ui/toggle"; + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx new file mode 100644 index 00000000..b45b9084 --- /dev/null +++ b/frontend/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3 min-w-10", + sm: "h-9 px-2.5 min-w-9", + lg: "h-11 px-5 min-w-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +export { Toggle, toggleVariants }; diff --git a/frontend/src/routes/question.tsx b/frontend/src/routes/question.tsx index 35f892f6..d4a54336 100644 --- a/frontend/src/routes/question.tsx +++ b/frontend/src/routes/question.tsx @@ -1,34 +1,34 @@ import LinkIcon from "@/assets/Icon/General/Link.svg?react"; +import placeholderProfile from "@/assets/placeholder_profile.png"; import { Answers } from "@/components/Answers"; +import BookmarkButton from "@/components/BookmarkButton"; import { ContentWithSnippets } from "@/components/ContentWithSnippets"; import { CreateAnswerForm } from "@/components/CreateAnswerForm"; import { DifficultyBar } from "@/components/DifficultyBar"; import ErrorAlert from "@/components/ErrorAlert"; import { ExerciseCard } from "@/components/ExerciseCard"; import FollowButton from "@/components/FollowButton"; -import BookmarkButton from "@/components/BookmarkButton"; import { FullscreenLoading } from "@/components/FullscreenLoading"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/use-toast"; import { TagDetails } from "@/services/api/programmingForumSchemas"; -import placeholderProfile from "@/assets/placeholder_profile.png"; +import { MultiSelect } from "@/components/multi-select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { useDeleteQuestion as useDeleteQuestionById, useDownvoteQuestion, useGetQuestionDetails, - useUpvoteQuestion, - useUpdateQuestion, useSearchTags, + useUpdateQuestion, + useUpvoteQuestion, } from "@/services/api/programmingForumComponents"; -import { MultiSelect } from "@/components/multi-select"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import useAuthStore from "@/services/auth"; import { convertTagToTrack, useExercismSearch } from "@/services/exercism"; import { Flag, MessageSquare, ThumbsDown, ThumbsUp, Trash } from "lucide-react"; -import { useEffect,useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; export default function QuestionPage() { @@ -97,25 +97,27 @@ export default function QuestionPage() { { queryParams: { q: "", pageSize: 1000 } }, { enabled: true }, ); - + useEffect(() => { if (tagSearchData?.data) { const tagsData = (tagSearchData.data as { items: TagDetails[] }).items; setAvailableTags(tagsData); } }, [tagSearchData]); - - const question = data! || {}; const [isEditing, setIsEditing] = useState(false); // To toggle edit mode const [isPreviewMode, setIsPreviewMode] = useState(false); // Preview toggle for description - + const titleRef = useRef(null); const contentRef = useRef(null); - const [tags, setTags] = useState(question.tags?.map((tag) => Number(tag.id)) || []); // Tag IDs state - const [availableTags, setAvailableTags] = useState<{ tagId: string; name: string }[]>([]); // Available tags + const [tags, setTags] = useState( + question.tags?.map((tag) => Number(tag.id)) || [], + ); // Tag IDs state + const [availableTags, setAvailableTags] = useState< + { tagId: string; name: string }[] + >([]); // Available tags const { mutateAsync: updateQuestion, isPending } = useUpdateQuestion({ onSuccess: () => { @@ -123,7 +125,6 @@ export default function QuestionPage() { setIsEditing(false); }, }); - const saveChanges = async () => { try { @@ -141,11 +142,9 @@ export default function QuestionPage() { description: "The question has been updated successfully.", }); setIsEditing(false); - } catch (err) {console.error( - "Failed to save changes", - err - ); - toast({ + } catch (err) { + console.error("Failed to save changes", err); + toast({ variant: "destructive", title: "Failed to save changes", description: "An error occurred while updating the question.", @@ -175,14 +174,15 @@ export default function QuestionPage() { {/* Left Column: Question and Answers */}
- {isEditing ? ( - - ) : ( -

{question.title}

- )} + {isEditing ? ( + + ) : ( +

{question.title}

+ )}
@@ -226,8 +230,7 @@ export default function QuestionPage() { className="flex items-center gap-4" > {"Profile @@ -292,7 +295,7 @@ export default function QuestionPage() { label: tag.name || "Loading...", }))} value={tags.map((tag) => String(tag))} - onValueChange={(selectedIds) =>{ + onValueChange={(selectedIds) => { const selectedTags = selectedIds.map((id) => Number(id)); // Convert back to numbers setTags(selectedTags); }} @@ -301,13 +304,12 @@ export default function QuestionPage() { ) : (
{question.tags.map((s) => ( - + {s.name} ))}
)} - Asked: {new Date(question.createdAt).toLocaleDateString()} @@ -318,19 +320,31 @@ export default function QuestionPage() { {isEditing ? (
- -
{isPreviewMode ? (
- +
) : ( -