diff --git a/.github/workflows/prod_deploy.yml b/.github/workflows/prod_deploy.yml index e6463904..9094564d 100644 --- a/.github/workflows/prod_deploy.yml +++ b/.github/workflows/prod_deploy.yml @@ -20,10 +20,10 @@ jobs: # Build a Docker image of the application and tag the image with the $GITHUB_SHA. - name: Build backend image - run: docker build -t registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) ./backend + run: docker build -t registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) ./backend - name: Build web image - run: docker build -t registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) ./frontend + run: docker build -t registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) ./frontend - name: Log in to DigitalOcean Container Registry with short-lived credentials run: doctl registry login --expiry-seconds 1200 @@ -31,23 +31,26 @@ jobs: # Push the Docker image to registry - name: Push backend image - run: docker push registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) + run: docker push registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) - name: Push web image - run: docker push registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) + run: docker push registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) # tag as latest - name: Tag backend image - run: docker tag registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages/backend:latest + run: docker tag registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages-2/backend:latest - name: Tag web image - run: docker tag registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages/web:latest + run: docker tag registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages-2/web:latest # Push the Docker image to registry - name: Push backend image - run: docker push registry.digitalocean.com/programming-languages/backend:latest + run: docker push registry.digitalocean.com/programming-languages-2/backend:latest - name: Push web image - run: docker push registry.digitalocean.com/programming-languages/web:latest \ No newline at end of file + run: docker push registry.digitalocean.com/programming-languages-2/web:latest + + - name: Create deployment again to ensure both images are up to date + run: doctl apps create-deployment 58cc1048-2af2-42f8-a71f-5d1085113742 \ No newline at end of file diff --git a/README.md b/README.md index a603425d..e19aa899 100644 --- a/README.md +++ b/README.md @@ -114,24 +114,24 @@ docker compose build ```bash # for prod -docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages/web:latest -docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages/backend:latest +docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages-2/web:latest +docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages-2/backend:latest # for staging -docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages/web:staging -docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages/backend:staging +docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages-2/web:staging +docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages-2/backend:staging ``` 3. Push images to the registry. ```bash # for prod -docker push registry.digitalocean.com/programming-languages/web:latest -docker push registry.digitalocean.com/programming-languages/backend:latest +docker push registry.digitalocean.com/programming-languages-2/web:latest +docker push registry.digitalocean.com/programming-languages-2/backend:latest # for staging -docker push registry.digitalocean.com/programming-languages/web:staging -docker push registry.digitalocean.com/programming-languages/backend:staging +docker push registry.digitalocean.com/programming-languages-2/web:staging +docker push registry.digitalocean.com/programming-languages-2/backend:staging ``` This will trigger a deployment on the DigitalOcean backend. diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java index aa0d72a7..0b13aa85 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java @@ -12,6 +12,7 @@ import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Services.TagService; import com.group1.programminglanguagesforum.Services.UserContextService; +import com.group1.programminglanguagesforum.Services.UserService; import com.group1.programminglanguagesforum.Util.ApiResponseBuilder; import jakarta.persistence.EntityExistsException; @@ -26,6 +27,7 @@ import java.util.Arrays; import java.util.NoSuchElementException; +import java.util.Optional; @RestController @@ -35,6 +37,7 @@ public class TagController extends BaseController { private final TagService tagService; private final UserContextService userContextService; + private final UserService userService; @GetMapping(value = EndpointConstants.TagEndpoints.SEARCH) public ResponseEntity> tagSearch( @@ -94,6 +97,13 @@ public ResponseEntity> getTagDetail @PostMapping(value = EndpointConstants.TagEndpoints.BASE_PATH) public ResponseEntity> createTag(@RequestBody CreateTagRequestDto dto){ try{ + User user = userContextService.getCurrentUser(); + if(userService.calculateReputation(user) < 50){ + return ExceptionResponseHandler.IllegalArgumentException( + new IllegalArgumentException("User does not have enough reputation to create a tag which should be at least 50") + ); + } + GetTagDetailsResponseDto tagDetails = tagService.createTag(dto); GenericApiResponse response = GenericApiResponse.builder() .status(201) @@ -115,6 +125,8 @@ public ResponseEntity> createTag(@R .build(); ApiResponseBuilder.buildErrorResponse(GetTagDetailsResponseDto.class, "Invalid tag type", 400, errorResponse); return buildResponse(response, HttpStatus.BAD_REQUEST); + } catch (UnauthorizedAccessException e) { + return ExceptionResponseHandler.UnauthorizedAccessException(e); } } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java index da282981..df4999eb 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java @@ -14,6 +14,10 @@ @Repository public interface TagRepository extends JpaRepository { List findAllByIdIn(List ids); + @Query("SELECT t FROM Tag t " + + "JOIN Question q ON t MEMBER OF q.tags " + + "GROUP BY t.id " + + "ORDER BY COUNT(q.id) DESC") Page findTagsByTagNameContainingIgnoreCase(String tagName, Pageable pageable); @Query("SELECT t FROM Tag t JOIN t.followers u WHERE u.id = :userId") diff --git a/frontend/src/components/AnswerCard.tsx b/frontend/src/components/AnswerCard.tsx index 10b7fcce..a5736d3e 100644 --- a/frontend/src/components/AnswerCard.tsx +++ b/frontend/src/components/AnswerCard.tsx @@ -24,7 +24,7 @@ export const AnswerCard: React.FC = ({ author, }) => { return ( - +

{title} @@ -49,7 +49,7 @@ export const AnswerCard: React.FC = ({ {author.name} diff --git a/frontend/src/components/AnswerItem.tsx b/frontend/src/components/AnswerItem.tsx index 207badc0..9ffe68fc 100644 --- a/frontend/src/components/AnswerItem.tsx +++ b/frontend/src/components/AnswerItem.tsx @@ -21,7 +21,7 @@ export const AnswerItem: React.FC = ({ const { token } = useAuthStore(); return ( - +
@@ -64,7 +64,7 @@ export const AnswerItem: React.FC = ({ answer.author?.profilePicture || "https://placehold.co/100x100" } - alt={answer.author?.name} + alt={"Profile picture"} className="h-8 w-8 rounded-full object-cover" /> {answer.author?.name} diff --git a/frontend/src/components/CodeSnippet.tsx b/frontend/src/components/CodeSnippet.tsx index d5a734de..b59b6162 100644 --- a/frontend/src/components/CodeSnippet.tsx +++ b/frontend/src/components/CodeSnippet.tsx @@ -5,7 +5,7 @@ import { useExecuteCode } from "@/services/api/programmingForumComponents"; import { CodeExecution } from "@/services/api/programmingForumSchemas"; import React from "react"; import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; -import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { a11yLight } from "react-syntax-highlighter/dist/esm/styles/hljs"; interface CodeSnippetProps { code: string; @@ -37,15 +37,15 @@ export const CodeSnippet: React.FC = ({ code, language }) => { return (
-

+

{languageUserFriendlyName[ language.replace("-exec", "") as keyof typeof languageUserFriendlyName ] ?? "Unknown"}{" "} Code Snippet -

+

{ return (
diff --git a/frontend/src/components/DifficultyBar.tsx b/frontend/src/components/DifficultyBar.tsx index bafd92b1..80f6125b 100644 --- a/frontend/src/components/DifficultyBar.tsx +++ b/frontend/src/components/DifficultyBar.tsx @@ -107,7 +107,7 @@ export const DifficultyBar: React.FC = ({ {/* Difficulty Bar */}

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

Easy: {localCounts.easy} votes,{" "} diff --git a/frontend/src/components/DifficultyFilter.tsx b/frontend/src/components/DifficultyFilter.tsx index aabdba6c..d3e01181 100644 --- a/frontend/src/components/DifficultyFilter.tsx +++ b/frontend/src/components/DifficultyFilter.tsx @@ -21,7 +21,7 @@ export function DifficultyFilter({ value, onChange }: DifficultyFilterProps) { onChange(val === "all" ? undefined : (val as DifficultyLevel)) } > - + diff --git a/frontend/src/components/ExerciseCard.tsx b/frontend/src/components/ExerciseCard.tsx index de717f0e..221a8db6 100644 --- a/frontend/src/components/ExerciseCard.tsx +++ b/frontend/src/components/ExerciseCard.tsx @@ -20,7 +20,7 @@ export const ExerciseCard: React.FC = ({ link, }) => { return ( - +

{title} diff --git a/frontend/src/components/HighlightedQuestionCard.tsx b/frontend/src/components/HighlightedQuestionCard.tsx index 8926b146..3d9214e8 100644 --- a/frontend/src/components/HighlightedQuestionCard.tsx +++ b/frontend/src/components/HighlightedQuestionCard.tsx @@ -54,7 +54,7 @@ export const HighlightedQuestionCard: React.FC> = ({ )} Go to question diff --git a/frontend/src/components/QuestionCard.tsx b/frontend/src/components/QuestionCard.tsx index b88a32bc..6e674181 100644 --- a/frontend/src/components/QuestionCard.tsx +++ b/frontend/src/components/QuestionCard.tsx @@ -27,7 +27,7 @@ export const QuestionCard = React.forwardRef( ({ id, title, content, votes, answerCount, author, difficulty }, ref) => { return (
@@ -65,7 +65,7 @@ export const QuestionCard = React.forwardRef( )} Go to question diff --git a/frontend/src/components/Tag.tsx b/frontend/src/components/Tag.tsx index 4af752b7..d12ea132 100644 --- a/frontend/src/components/Tag.tsx +++ b/frontend/src/components/Tag.tsx @@ -45,7 +45,7 @@ export const Tag = ({ )} See all questions → diff --git a/frontend/src/components/TagCard.tsx b/frontend/src/components/TagCard.tsx index a186610f..da470d9c 100644 --- a/frontend/src/components/TagCard.tsx +++ b/frontend/src/components/TagCard.tsx @@ -12,13 +12,13 @@ export const TagCard = React.forwardRef( ({ tag }, ref) => { return (
-

+

{tag.name} -

+

{tag.description} diff --git a/frontend/src/routes/create-question.tsx b/frontend/src/routes/create-question.tsx index 63acc598..9b509f46 100644 --- a/frontend/src/routes/create-question.tsx +++ b/frontend/src/routes/create-question.tsx @@ -282,15 +282,19 @@ export default function QuestionCreationPage() { name="difficulty" render={({ field }) => ( + diff --git a/frontend/src/routes/feed.tsx b/frontend/src/routes/feed.tsx index f95e1031..9020665f 100644 --- a/frontend/src/routes/feed.tsx +++ b/frontend/src/routes/feed.tsx @@ -79,7 +79,7 @@ export const Feed = () => {

diff --git a/frontend/src/routes/profile.tsx b/frontend/src/routes/profile.tsx index 27858f73..13ef94f6 100644 --- a/frontend/src/routes/profile.tsx +++ b/frontend/src/routes/profile.tsx @@ -204,7 +204,7 @@ export default function Profile() { size="icon" className="rounded-full bg-red-500 text-white" > - + diff --git a/frontend/src/routes/question.tsx b/frontend/src/routes/question.tsx index d7f28b5b..5d2f7c26 100644 --- a/frontend/src/routes/question.tsx +++ b/frontend/src/routes/question.tsx @@ -151,7 +151,7 @@ export default function QuestionPage() { src={ question.author.profilePicture || "https://placehold.co/640x640" } - alt={question.author.name + " profile picture"} + alt={"Profile picture"} className="h-8 w-8 rounded-full object-cover" /> {question.author.name} diff --git a/frontend/src/routes/tag.tsx b/frontend/src/routes/tag.tsx index a5d4b96e..97ef006d 100644 --- a/frontend/src/routes/tag.tsx +++ b/frontend/src/routes/tag.tsx @@ -102,7 +102,7 @@ export default function TagPage() { {tag.logoImage && ( {`The @@ -161,6 +161,7 @@ export default function TagPage() { to={tag.stackExchangeTag} className="flex items-center gap-2 text-sm font-semibold text-gray-500" target="_blank" + aria-label={`Visit Stack Exchange for ${tag.name}`} > Stack Exchange @@ -177,15 +178,16 @@ export default function TagPage() {
-

Questions

+

Questions

{!!token && ( )} diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index c84e47e4..60ef4433 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,9 +1,10 @@ import { ScrollView, Text } from "react-native"; -import { Button, ButtonText, VStack } from "@/components/ui"; +import { Button, ButtonText, Divider, VStack } from "@/components/ui"; import useAuthStore from "@/services/auth"; import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; +import { Feed } from "@/components/Feed"; export default function HomeScreen() { const auth = useAuthStore(); @@ -45,6 +46,8 @@ export default function HomeScreen() { Logout )} + + ); diff --git a/mobile/app/question/[questionId].tsx b/mobile/app/question/[questionId].tsx index 9d840ef9..7eb83e46 100644 --- a/mobile/app/question/[questionId].tsx +++ b/mobile/app/question/[questionId].tsx @@ -145,7 +145,7 @@ export default function QuestionPage() { } return ( - + + + + + + {tag.name} + + {tag.name} + {tag.description} - - - Questions - {!!token && ( - + + + Questions + {!!token && ( + + + + )} + + + - - )} - - - - - + + + + + - {highlightedQuestions?.map((question) => ( - - ))} - {tab === "top-rated" ? ( - - {questions && - questions.map((question) => ( - - ))} - - ) : ( - - {questions && - questions.map((question) => ( - - ))} - - )} - - - + + + + + ); } diff --git a/mobile/components/AnswerCard.tsx b/mobile/components/AnswerCard.tsx index 760e3300..fb9864c7 100644 --- a/mobile/components/AnswerCard.tsx +++ b/mobile/components/AnswerCard.tsx @@ -26,7 +26,7 @@ export const AnswerCard: React.FC = ({ author, }) => { return ( - + {title} diff --git a/mobile/components/AnswerItem.tsx b/mobile/components/AnswerItem.tsx index 6037089c..e0d173f8 100644 --- a/mobile/components/AnswerItem.tsx +++ b/mobile/components/AnswerItem.tsx @@ -23,7 +23,7 @@ export const AnswerItem: React.FC = ({ const { token } = useAuthStore(); return ( - + diff --git a/mobile/components/Feed.tsx b/mobile/components/Feed.tsx new file mode 100644 index 00000000..1e213b43 --- /dev/null +++ b/mobile/components/Feed.tsx @@ -0,0 +1,17 @@ +import { TagList } from "@/components/TagList"; +import { Divider, Text, VStack } from "@/components/ui"; +import { QuestionList } from "./QuestionsList"; +import { Divide } from "lucide-react-native"; + +export const Feed = () => { + + return ( + + Tags + + + Latest Questions + + + ); +}; diff --git a/mobile/components/QuestionCard.tsx b/mobile/components/QuestionCard.tsx index d597697f..9e218b0a 100644 --- a/mobile/components/QuestionCard.tsx +++ b/mobile/components/QuestionCard.tsx @@ -1,15 +1,8 @@ -import { Icon, Image, Text } from "@/components/ui"; -import { Card } from "@/components/ui/card"; +import { Card, HStack, Icon, Image, Text, View } from "@/components/ui"; import { DifficultyLevel } from "@/services/api/programmingForumSchemas"; import { Link } from "expo-router"; -import { - ArrowRight, - MessageSquare, - Star, - StarsIcon, -} from "lucide-react-native"; +import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react-native"; import React from "react"; -import { View } from "react-native"; interface QuestionCardProps { id: string; @@ -25,6 +18,7 @@ interface QuestionCardProps { }; highlighted?: boolean; } + const capitalizeString = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); }; @@ -41,15 +35,44 @@ export const QuestionCard: React.FC = ({ }) => { return ( - {highlighted && ( - - Beginner Friendly - + {(highlighted || answerCount === 0) && ( + + {highlighted && ( + + Beginner Friendly + + )} + + {/* Unanswered notification with background */} + {answerCount === 0 && ( + + Unanswered + + )} + + ) + } + + {author && ( + + + {author.name + + + {author?.name} + + )} = ({ > {content} - + {votes} votes @@ -83,20 +106,12 @@ export const QuestionCard: React.FC = ({ {capitalizeString(difficulty)} )} - + + - {author && ( - - {author.name} - - )} diff --git a/mobile/components/QuestionsList.tsx b/mobile/components/QuestionsList.tsx new file mode 100644 index 00000000..c373469f --- /dev/null +++ b/mobile/components/QuestionsList.tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +import { View, Text, Button, ActivityIndicator } from "react-native"; +import { QuestionCard } from "@/components/QuestionCard"; +import { + SearchQuestionsQueryParams, + useSearchQuestions, + SearchQuestionsResponse, +} from "@/services/api/programmingForumComponents"; + +interface QuestionListProps { + searchQueryParams?: string; + pageSize?: number; + difficultyFilter?: "EASY" | "MEDIUM" | "HARD"; + tagFilter?: string; + sortBy?: "RECENT" | "TOP_RATED"; +} + +export const QuestionList: React.FC = ({ + searchQueryParams = "", + pageSize = 10, + difficultyFilter, + tagFilter = "", + sortBy = "RECENT", +}) => { + const [page, setPage] = useState(1); + + const q: SearchQuestionsQueryParams = { + q: searchQueryParams, + page, + pageSize, + ...(difficultyFilter && { difficulty: difficultyFilter }), + ...(tagFilter && { tags: tagFilter }), + }; + + // Fetch questions + const { + data: questionSearchData, + isLoading: isLoadingQuestions, + error: questionsError, + } = useSearchQuestions({ + queryParams: q, + }); + + const isQuestionsResponseObject = ( + data: SearchQuestionsResponse["data"] + ): data is Exclude => { + return typeof data === "object" && !Array.isArray(data); + }; + + const questions = + questionSearchData && isQuestionsResponseObject(questionSearchData.data) + ? questionSearchData.data.items + : []; + const totalPages = + questionSearchData && isQuestionsResponseObject(questionSearchData.data) + ? questionSearchData.data.totalPages + : 0; + + const handleNextPage = () => { + if (page < (totalPages ?? 0)) setPage((prev) => prev + 1); + }; + + const handlePreviousPage = () => { + if (page > 1) setPage((prev) => prev - 1); + }; + + return ( + + {isLoadingQuestions && } + {questionsError && Error: {questionsError.status}} + {questions && ( + + {questions + .sort((a, b) => { + if (sortBy === "RECENT") { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } else { + return (b.upvoteCount - b.downvoteCount) - (a.upvoteCount - a.downvoteCount); + } + }) + .map((question) => ( + + ))} + + )} + +