From 73ab9662ea32f0cba559aa6bdc1ac89bff5d2755 Mon Sep 17 00:00:00 2001 From: Max Xue Date: Thu, 12 Oct 2023 01:29:43 +1100 Subject: [PATCH 1/2] Finished discover page filtering --- frontend/app/discover/BlogArticles.tsx | 111 +++ frontend/app/discover/CategorySection.tsx | 72 -- frontend/app/discover/DiscoverPageHeader.tsx | 65 ++ frontend/app/discover/Filter.tsx | 160 ++++ frontend/app/discover/PaginationButton.tsx | 68 ++ frontend/app/discover/SearchBar.tsx | 18 - ...ion.module.css => blogArticles.module.css} | 4 +- frontend/app/discover/filter.module.css | 75 ++ frontend/app/discover/page.tsx | 68 +- frontend/app/discover/searchBar.module.css | 3 - frontend/app/discover/styles.module.css | 32 + frontend/app/layout.tsx | 38 +- .../components/blog-card/styles.module.css | 4 +- frontend/components/footer/Footer.tsx | 31 +- frontend/package-lock.json | 763 ++++++++++++++++++ frontend/package.json | 2 + 16 files changed, 1367 insertions(+), 147 deletions(-) create mode 100644 frontend/app/discover/BlogArticles.tsx delete mode 100644 frontend/app/discover/CategorySection.tsx create mode 100644 frontend/app/discover/DiscoverPageHeader.tsx create mode 100644 frontend/app/discover/Filter.tsx create mode 100644 frontend/app/discover/PaginationButton.tsx delete mode 100644 frontend/app/discover/SearchBar.tsx rename frontend/app/discover/{categorySection.module.css => blogArticles.module.css} (93%) create mode 100644 frontend/app/discover/filter.module.css delete mode 100644 frontend/app/discover/searchBar.module.css diff --git a/frontend/app/discover/BlogArticles.tsx b/frontend/app/discover/BlogArticles.tsx new file mode 100644 index 0000000..5c4de2c --- /dev/null +++ b/frontend/app/discover/BlogArticles.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import styles from "./blogArticles.module.css"; +import { BlogCard } from "@/components/blog-card/BlogCard"; +import "dotenv/config"; +import PaginationButton from "./PaginationButton"; + +const PAGINATION_INTERVAL = 9; + +const getArticles = async ( + searchParams: + | { + [key: string]: string | string[] | undefined; + } + | undefined +) => { + if (searchParams === undefined) { + const res = await fetch( + `${process.env.NEXT_PUBLIC_SERVER_ROUTE}api/articles?pagination[start]=0&pagination[limit]=${PAGINATION_INTERVAL}&pagination[withCount]=true&sort[0]=updatedAt:desc`, + { + cache: "no-store", + } + ); + if (!res.ok) { + throw new Error("Failed to fetch data"); + } + return res.json(); + } + + let strapiQueryString = `${process.env.NEXT_PUBLIC_SERVER_ROUTE}api/articles?`; + + if (searchParams.category !== undefined) { + strapiQueryString = `${strapiQueryString}filters[category][id][$eq]=${searchParams.category}`; + } + + if (searchParams.tags !== undefined) { + if (searchParams.category !== undefined) { + strapiQueryString = `${strapiQueryString}&`; + } + + let tagQueryStr = ""; + + if (Array.isArray(searchParams.tags)) { + searchParams.tags.map((t) => { + tagQueryStr = `${tagQueryStr}filters[$and][${t}][tags][id][$eq]=${t}&`; + }); + tagQueryStr = tagQueryStr.slice(0, -1); // Remove last & + } else { + tagQueryStr = `${tagQueryStr}filters[tags][id][$eq]=${searchParams.tags}`; + } + + strapiQueryString = `${strapiQueryString}${tagQueryStr}`; + } + + if ( + searchParams.category !== undefined || + searchParams.tags !== undefined + ) { + strapiQueryString = `${strapiQueryString}&`; + } + if (searchParams.pagination !== undefined) { + strapiQueryString = `${strapiQueryString}pagination[start]=0&pagination[limit]=${searchParams.pagination}&pagination[withCount]=true&sort[0]=updatedAt:desc`; + } else { + strapiQueryString = `${strapiQueryString}pagination[start]=0&pagination[limit]=${PAGINATION_INTERVAL}&pagination[withCount]=true&sort[0]=updatedAt:desc`; + } + + const res = await fetch(`${strapiQueryString}`, { + cache: "no-store", + }); + if (!res.ok) { + throw new Error("Failed to fetch data"); + } + return res.json(); + // Example full query: http://localhost:1337/api/articles?filters[$and][0][category][id][$eq]=1&filters[$and][1][tags][id][$eq]=2&populate=* +}; + +type Article = { + data: any; +}; + +export const BlogArticles = async ({ + searchParams, +}: { + searchParams?: { [key: string]: string | string[] | undefined }; +}) => { + const articles = await getArticles(searchParams); + + return ( +
+
+ {articles.data.map((a: any) => ( + + ))} +
+ {articles.meta.pagination.total !== articles.data.length && ( + + )} +
+ ); +}; diff --git a/frontend/app/discover/CategorySection.tsx b/frontend/app/discover/CategorySection.tsx deleted file mode 100644 index b6b2146..0000000 --- a/frontend/app/discover/CategorySection.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; -import React, { useEffect, useState } from "react"; -import styles from "./categorySection.module.css"; -import { BlogCard } from "@/components/blog-card/BlogCard"; -import 'dotenv/config' - -const getCategoryArticles = async (categoryId: any, pagination: number) => { - const res = await fetch( - `${process.env.NEXT_PUBLIC_SERVER_ROUTE}api/articles?filters[category][id][$eq]=${categoryId}&pagination[start]=0&pagination[limit]=${pagination}&pagination[withCount]=true&sort[0]=updatedAt:desc`, - { - cache: "no-store", - } - ); - http: if (!res.ok) { - throw new Error("Failed to fetch data"); - } - return res.json(); -}; - -type Article = { - data: any; -}; - -export const CategorySection = ({ - categoryId, - name, -}: { - categoryId: string; - name: string; -}) => { - // const articles = await getCategoryArticles(categoryId); - const [articles, setArticles] = useState({ - data: [], - meta: { pagination: { total: 0 } }, - }); - const [articleNum, setArticleNum] = useState(3); - useEffect(() => { - const setArticlesInit = async () => { - const res = await getCategoryArticles(categoryId, articleNum); - setArticles(res); - }; - setArticlesInit(); - }, [categoryId, articleNum]); - - return ( -
-

{name}

-
- {articles.data.map((a: any) => ( - - ))} -
- {articles.meta.pagination.total !== articles.data.length && ( - - )} -
- ); -}; diff --git a/frontend/app/discover/DiscoverPageHeader.tsx b/frontend/app/discover/DiscoverPageHeader.tsx new file mode 100644 index 0000000..367c7a5 --- /dev/null +++ b/frontend/app/discover/DiscoverPageHeader.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import styles from "./styles.module.css"; +import { ListFilter } from "lucide-react"; +import Filter from "./Filter"; +import { useHoverDirty } from "react-use"; + +export const DiscoverPageHeader = ({ + tags = [], + categories = [], + searchParams, +}: { + tags: { id: string; name: string }[]; + categories: { id: string; name: string }[]; + searchParams?: { [key: string]: string | string[] | undefined }; +}) => { + const [isFilterOpen, setIsFilterOpen] = useState(false); + + const filterRef = useRef(null); + const isFilterHovered = useHoverDirty(filterRef); + + const buttonRef = useRef(null); + const isButtonHovered = useHoverDirty(buttonRef); + + useEffect(() => { + const handleOutsideOpenFilterClick = (e: MouseEvent) => { + if (isFilterOpen && !isFilterHovered && !isButtonHovered) { + setIsFilterOpen(false); + } + }; + window.addEventListener("click", handleOutsideOpenFilterClick); + + return () => { + window.removeEventListener("click", handleOutsideOpenFilterClick); + }; + }, [isFilterOpen, isFilterHovered, isButtonHovered]); + + return ( +
+
+

Discover

+ + { + setIsFilterOpen(false); + }} + /> +
+
+ ); +}; diff --git a/frontend/app/discover/Filter.tsx b/frontend/app/discover/Filter.tsx new file mode 100644 index 0000000..3dc0af5 --- /dev/null +++ b/frontend/app/discover/Filter.tsx @@ -0,0 +1,160 @@ +import React, { useState, forwardRef } from "react"; +import styles from "./filter.module.css"; +import { useRouter } from "next/navigation"; +import Select, { SingleValue } from "react-select"; + +const initSelectedTags = ( + tags: { id: string; name: string }[], + searchParams?: { [key: string]: string | string[] | undefined } +) => { + let tagsInit: { [key: string]: boolean } = {}; + tags.forEach((t) => { + tagsInit[t.id] = false; + }); + if (searchParams !== undefined && Array.isArray(searchParams.tags)) { + searchParams.tags.forEach((tid) => { + tagsInit[tid] = true; + }); + } + + return tagsInit; +}; + +const initSelectedCategory = (searchParams?: { + [key: string]: string | string[] | undefined; +}) => { + if (searchParams !== undefined && searchParams.category !== undefined) { + return searchParams.category; + } else { + return "-1"; + } +}; + +type Props = { + isOpen: boolean; + tags: { id: string; name: string }[]; + categories: { id: string; name: string }[]; + searchParams?: { [key: string]: string | string[] | undefined }; + closeFilter: () => void; +}; + +const Filter = forwardRef(function Filter( + { isOpen, tags, categories, searchParams, closeFilter }, + ref +) { + const [selectedCategory, setSelectedCategory] = useState( + initSelectedCategory(searchParams) + ); + const [selectedTags, setSelectedTags] = useState<{ + [key: string]: boolean; + }>(initSelectedTags(tags, searchParams)); + + const router = useRouter(); + + const handleSubmit = () => { + let url = "/discover"; + let categoryParam = ""; + if (selectedCategory !== "-1") { + categoryParam = `category=${selectedCategory}`; + } + let tagsParam = ""; + let tagsParamArr = []; + for (const t in selectedTags) { + if (selectedTags[t] === true) { + tagsParamArr.push(t); + } + } + if (tagsParamArr.length !== 0) { + tagsParam = `tags=${tagsParamArr.join("&tags=")}`; + } + + if (categoryParam !== "" && tagsParam !== "") { + url = `${url}?${categoryParam}&${tagsParam}`; + } else if (categoryParam !== "") { + url = `${url}?${categoryParam}`; + } else if (tagsParam !== "") { + url = `${url}?${tagsParam}`; + } + + router.push(url); + closeFilter(); + }; + + const selectOptions = [{ id: "-1", name: "All" }, ...categories].map( + (c) => { + return { + value: c.id, + label: c.name, + }; + } + ); + + const handleSelect = ( + selectedOption: SingleValue<{ + value: string; + label: string; + }> + ) => { + if (selectedOption) setSelectedCategory(selectedOption.value); + }; + + return ( +
+
+
+ + { + const newSelectedTags = { + ...selectedTags, + }; + newSelectedTags[t.id] = + e.target.checked; + setSelectedTags(newSelectedTags); + }} + /> + +
+ ); + })} +
+
+ + + + ); +}); + +export default Filter; diff --git a/frontend/app/discover/PaginationButton.tsx b/frontend/app/discover/PaginationButton.tsx new file mode 100644 index 0000000..6a3a778 --- /dev/null +++ b/frontend/app/discover/PaginationButton.tsx @@ -0,0 +1,68 @@ +"use client"; + +import React from "react"; +import styles from "./blogArticles.module.css"; +import { useRouter } from "next/navigation"; + +export default function PaginationButton({ + paginationInterval, + searchParams, +}: { + paginationInterval: number; + searchParams?: { [key: string]: string | string[] | undefined }; +}) { + const router = useRouter(); + return ( + + ); +} diff --git a/frontend/app/discover/SearchBar.tsx b/frontend/app/discover/SearchBar.tsx deleted file mode 100644 index 9731821..0000000 --- a/frontend/app/discover/SearchBar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; -import React, { ChangeEvent, useState } from "react"; -import styles from "./searchBar.module.css"; - -export default function SearchBar() { - const [searchInput, setSearchInput] = useState(""); - - return ( - - setSearchInput((e.target as HTMLInputElement).value) - } - /> - ); -} diff --git a/frontend/app/discover/categorySection.module.css b/frontend/app/discover/blogArticles.module.css similarity index 93% rename from frontend/app/discover/categorySection.module.css rename to frontend/app/discover/blogArticles.module.css index 3dcde72..4207445 100644 --- a/frontend/app/discover/categorySection.module.css +++ b/frontend/app/discover/blogArticles.module.css @@ -16,7 +16,7 @@ all: unset; cursor: pointer; align-self: flex-end; - padding: 5px 10px; + padding: 4px 6px; border-radius: 4px; border-width: 1px; @@ -25,7 +25,7 @@ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); color: rgb(156 163 175); - font-size: 14px; + font-size: 12px; } .seeMoreBtn:hover { diff --git a/frontend/app/discover/filter.module.css b/frontend/app/discover/filter.module.css new file mode 100644 index 0000000..5fd3e10 --- /dev/null +++ b/frontend/app/discover/filter.module.css @@ -0,0 +1,75 @@ +.container { + position: absolute; + right: 0; + top: 60px; + box-shadow: rgba(0, 0, 0, 0.09) 0px 3px 12px 0px; + border-radius: 4px; + width: 350px; + background-color: white; + + transform: translateY(2px); + opacity: 0; + height: 0; + overflow: hidden; + + transition: all 100ms ease-in; +} + +.openContainer { + transform: translateY(0); + opacity: 1; + height: auto; +} + +.content { + padding: 20px; + display: flex; + flex-direction: column; +} + +.title { + margin-bottom: 10px; + font-weight: bold; +} + +.categoriesContainer { + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.tagsContainer { + margin-bottom: 10px; +} + +.tagsGrid { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.filterBtn { + all: unset; + box-shadow: rgba(0, 0, 0, 0.09) 0px 1px 4px; + border: 1px solid rgb(223, 225, 228); + color: rgb(60, 65, 73); + height: fit-content; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + user-select: none; + font-size: 14px; + transition: background-color 100ms ease-in; + display: flex; + gap: 5px; + width: fit-content; + align-self: flex-end; +} + +.filterBtn:hover { + background-color: rgb(0, 0, 0, 0.025); +} + + +.filterBtn:active { + background-color: rgb(0, 0, 0, 0.05); +} \ No newline at end of file diff --git a/frontend/app/discover/page.tsx b/frontend/app/discover/page.tsx index fa7505c..5489c18 100644 --- a/frontend/app/discover/page.tsx +++ b/frontend/app/discover/page.tsx @@ -1,9 +1,8 @@ -import { BlogCard } from "@/components/blog-card/BlogCard"; import React from "react"; -import 'dotenv/config' +import "dotenv/config"; import styles from "./styles.module.css"; -import { CategorySection } from "./CategorySection"; -import SearchBar from "./SearchBar"; +import { DiscoverPageHeader } from "./DiscoverPageHeader"; +import { BlogArticles } from "./BlogArticles"; async function getCategories() { const res = await fetch(`${process.env.SERVER_ROUTE}api/categories/`, { @@ -12,31 +11,66 @@ async function getCategories() { if (!res.ok) { throw new Error("Failed to fetch data"); } - return res.json(); + + const data = await res.json(); + + const categories: any[] = data.data.map((c: any) => { + return { + id: c.id, + name: c.attributes.name, + }; + }); + + return categories; } -export default async function DiscoverPage() { - const categories = await getCategories(); - console.log(categories); +async function getTags() { + const res = await fetch(`${process.env.SERVER_ROUTE}api/tags/`, { + cache: "no-store", + }); + if (!res.ok) { + throw new Error("Failed to fetch data"); + } + const data = await res.json(); + + const tags: any[] = data.data.map((t: any) => { + return { + id: t.id, + name: t.attributes.name, + }; + }); + + return tags; +} + +export default async function DiscoverPage({ + searchParams, +}: { + searchParams?: { [key: string]: string | string[] | undefined }; +}) { + const categories: { id: string; name: string }[] = await getCategories(); + const tags: { id: string; name: string }[] = await getTags(); + console.log(searchParams); + return (
-

Discover

- {/*
- - - -
*/} +
- {categories.data.map((category: any) => { + + {/* {categories.map((category: any) => { return ( ); - })} + })} */}
diff --git a/frontend/app/discover/searchBar.module.css b/frontend/app/discover/searchBar.module.css deleted file mode 100644 index 794571f..0000000 --- a/frontend/app/discover/searchBar.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.container { - flex: 1; -} diff --git a/frontend/app/discover/styles.module.css b/frontend/app/discover/styles.module.css index d72209c..799cc89 100644 --- a/frontend/app/discover/styles.module.css +++ b/frontend/app/discover/styles.module.css @@ -11,4 +11,36 @@ .searchBar { flex: 1; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + position: relative; +} + +.filterBtn { + all: unset; + box-shadow: rgba(0, 0, 0, 0.09) 0px 1px 4px; + border: 1px solid rgb(223, 225, 228); + color: rgb(60, 65, 73); + height: fit-content; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + user-select: none; + font-size: 14px; + transition: background-color 100ms ease-in; + display: flex; + gap: 5px; +} + +.filterBtn:hover { + background-color: rgb(0, 0, 0, 0.025); +} + + +.filterBtn:active { + background-color: rgb(0, 0, 0, 0.05); } \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index e55af8a..fb0749d 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,28 +1,28 @@ -import Navbar from '@/components/navbar/Navbar'; -import './globals.css'; -import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; -import Footer from '@/components/footer/Footer'; +import Navbar from "@/components/navbar/Navbar"; +import "./globals.css"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import Footer from "@/components/footer/Footer"; -const inter = Inter({ subsets: ['latin'] }); +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: "Create Next App", + description: "Generated by create next app", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - - - - {children} -