Skip to content

Commit a60236d

Browse files
authored
Merge pull request #376 from boostcampwm-2024/feat/filtering-ui
✨ feat: 포스트 필터링 및 추천 포스트 구현
2 parents dd08f3e + 90e6678 commit a60236d

File tree

13 files changed

+273
-16
lines changed

13 files changed

+273
-16
lines changed

client/src/api/services/posts.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { axiosInstance } from "@/api/instance";
44
import { InfiniteScrollResponse, LatestPostsApiResponse, Post, PostDetailType } from "@/types/post";
55

66
export const posts = {
7-
latest: async (params: { limit: number; lastId: number }): Promise<InfiniteScrollResponse<Post>> => {
7+
latest: async (params: { limit: number; lastId: number; tags: string[] }): Promise<InfiniteScrollResponse<Post>> => {
88
const response = await axiosInstance.get<LatestPostsApiResponse>(BLOG.POST, {
99
params: {
1010
limit: params.limit,
1111
lastId: params.lastId || 0,
12+
tags: params.tags || [],
1213
},
1314
});
1415
return {

client/src/components/common/Card/PostCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from "react-router-dom";
33
import { Card, MCard } from "@/components/ui/card";
44

55
import { usePostCardActions } from "@/hooks/common/usePostCardActions";
6+
import { useUpdateRecentTags } from "@/hooks/common/useUpdateRecentTag";
67

78
import { PostCardContent } from "./PostCardContent";
89
import { PostCardImage } from "./PostCardImage";
@@ -26,6 +27,7 @@ const DesktopCard = ({ post, className }: PostCardProps) => {
2627
const { incrementView } = usePostCardActions(post);
2728
const openPostDetail = (modalUrl: string) => {
2829
incrementView({ post, isWindowOpened: true });
30+
useUpdateRecentTags(post.tag);
2931
navigate(modalUrl, { state: { backgroundLocation: location } });
3032
};
3133
return (

client/src/components/common/Card/PostCardGrid.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { Post } from "@/types/post";
21
import { PostCard } from "./PostCard";
2+
import { Post } from "@/types/post";
33

44
interface PostCardGridProps {
55
posts: Post[];
66
}
77

88
export const PostCardGrid = ({ posts }: PostCardGridProps) => {
99
return (
10-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
10+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 min-h-[300px]">
1111
{posts.map((post) => {
1212
return <PostCard key={post.id} post={post} />;
1313
})}
Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,56 @@
1+
import clsx from "clsx";
12
import { LucideIcon } from "lucide-react";
23

4+
import { usePostTypeStore } from "@/store/usePostTypeStore";
5+
36
interface SectionHeaderProps {
47
icon: LucideIcon;
58
text: string;
69
iconColor: string;
710
description: string;
11+
secondText?: string;
12+
secondDescription?: string;
813
}
914

10-
export const SectionHeader = ({ icon: Icon, text, iconColor, description }: SectionHeaderProps) => {
15+
export const SectionHeader = ({
16+
icon: Icon,
17+
text,
18+
iconColor,
19+
description,
20+
secondText,
21+
secondDescription,
22+
}: SectionHeaderProps) => {
23+
const { postType, setPostType } = usePostTypeStore();
24+
25+
const hasSecond = !secondText || postType === "latest";
26+
1127
return (
12-
<div className="flex items-center gap-2 p-4 md:p-0">
13-
{Icon && (
14-
<div>
15-
<Icon className={`w-5 h-5 ${iconColor}`} />
16-
</div>
28+
<div className="whitespace-nowrap flex items-center gap-2 p-4 md:p-0">
29+
{Icon && <Icon className={`w-5 h-5 ${iconColor}`} />}
30+
31+
<h2
32+
className={clsx(
33+
"text-lg md:text-xl font-semibold",
34+
secondText && postType !== "latest" && "text-gray-400 cursor-pointer hover:text-black"
35+
)}
36+
onClick={() => secondText && setPostType("latest")}
37+
>
38+
{text}
39+
</h2>
40+
41+
{secondText && (
42+
<h2
43+
className={clsx(
44+
"text-lg md:text-xl font-semibold",
45+
postType !== "recommend" && "text-gray-400 cursor-pointer hover:text-black"
46+
)}
47+
onClick={() => setPostType("recommend")}
48+
>
49+
{secondText}
50+
</h2>
1751
)}
18-
<h2 className="text-xl font-semibold">{text}</h2>
19-
<p className="text-sm text-gray-400 mt-1">{description}</p>
52+
53+
<p className="text-xs md:text-sm text-gray-400 mt-1">{hasSecond ? description : secondDescription}</p>
2054
</div>
2155
);
2256
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useState } from "react";
2+
3+
import clsx from "clsx";
4+
5+
import { CATEGORIES, CATEGORIES_KEY, CATEGORIES_MAP } from "@/constants/filter";
6+
7+
import { useFilterStore } from "@/store/useFilterStore";
8+
9+
export default function Filter() {
10+
const [lastActiveCategory, setLastActiveCategory] = useState<string>("FrontEnd");
11+
const [filterOpen, setFilterOpen] = useState<boolean>(false);
12+
const handleFilterOpen = () => {
13+
setFilterOpen(!filterOpen);
14+
};
15+
16+
const handleCategoryChange = (category: string) => {
17+
setLastActiveCategory(category);
18+
};
19+
20+
return (
21+
<div className={`px-4 md:px-0 mt-0 md:pt-3 rounded-lg flex flex-col gap-1`}>
22+
<div className="flex gap-6">
23+
<span className="font-bold">카테고리</span>
24+
<button className="text-sm text-red-400" onClick={handleFilterOpen}>
25+
{filterOpen ? "닫기" : "열기"}
26+
</button>
27+
</div>
28+
{filterOpen && (
29+
<Filters
30+
filters={CATEGORIES[CATEGORIES_MAP[lastActiveCategory as keyof typeof CATEGORIES_MAP]]}
31+
keys={CATEGORIES_KEY}
32+
activeCategory={lastActiveCategory}
33+
onCategoryChange={handleCategoryChange}
34+
/>
35+
)}
36+
</div>
37+
);
38+
}
39+
40+
function Filters({
41+
filters,
42+
keys,
43+
activeCategory,
44+
onCategoryChange,
45+
}: {
46+
filters: string[];
47+
keys: string[];
48+
activeCategory: string;
49+
onCategoryChange: (category: string) => void;
50+
}) {
51+
const { filters: pickedFilter, addFilter, removeFilter } = useFilterStore();
52+
const commonClass = `w-fit select-none cursor-pointer`;
53+
return (
54+
<div className="flex flex-col">
55+
<ul className="py-2 flex gap-2">
56+
{keys.map((category, index) => (
57+
<li
58+
key={index}
59+
onClick={() => onCategoryChange(category)}
60+
className={clsx(
61+
"px-1 md:px-3 py-1 rounded cursor-pointer transition text-xs md:text-base",
62+
category === activeCategory ? "bg-secondary text-white" : "bg-white fext-secondary hover:bg-gray-100"
63+
)}
64+
>
65+
{category}
66+
</li>
67+
))}
68+
</ul>
69+
<ul className="flex flex-wrap gap-x-2 gap-y-2 place-items-start w-fit gap-1 py-2">
70+
{filters.map((filter, index) => (
71+
<li
72+
key={index}
73+
className={clsx(
74+
pickedFilter.includes(filter) ? "bg-primary text-white" : "bg-gray-200 hover:bg-gray-300 text-gray-800",
75+
commonClass,
76+
"px-2 md:px-3 text-xs md:text-sm py-1 rounded-full "
77+
)}
78+
onClick={() => (pickedFilter.includes(filter) ? removeFilter(filter) : addFilter(filter))}
79+
>
80+
{filter}
81+
</li>
82+
))}
83+
</ul>
84+
</div>
85+
);
86+
}

client/src/components/sections/AnimatedPostGrid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const AnimatedPostGrid = ({ posts = [] }: AnimatedPostGridProps) => {
1717
return <EmptyPost />;
1818
}
1919
return (
20-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 md:gap-6 relative">
20+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4 md:gap-6 relative">
2121
<AnimatePresence initial={false}>
2222
{posts.map((post) => {
2323
return (

client/src/components/sections/LatestSection.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import { useEffect, useRef } from "react";
22

3-
import { Rss } from "lucide-react";
3+
import { Rss, SquareX } from "lucide-react";
44

55
import { PostCardGrid } from "@/components/common/Card/PostCardGrid";
66
import { PostGridSkeleton } from "@/components/common/Card/PostCardSkeleton.tsx";
77
import { SectionHeader } from "@/components/common/SectionHeader";
8+
import Filter from "@/components/filter/Filter";
89

10+
import { useRecentTag } from "@/hooks/common/useRecentTag";
911
import { useInfiniteScrollQuery } from "@/hooks/queries/useInfiniteScrollQuery";
1012

13+
import { Badge } from "../ui/badge";
1114
import { posts } from "@/api/services/posts";
15+
import { useFilterStore } from "@/store/useFilterStore";
16+
import { usePostTypeStore } from "@/store/usePostTypeStore";
1217
import { Post } from "@/types/post";
1318

1419
export default function LatestSection() {
20+
const pickedFilter = useFilterStore((state) => state.filters);
21+
const removeFilter = useFilterStore((state) => state.removeAll);
1522
const observerTarget = useRef<HTMLDivElement>(null);
23+
const postType = usePostTypeStore((state) => state.postType);
24+
const recentTags = useRecentTag();
25+
const tags = postType === "latest" ? pickedFilter : recentTags;
26+
1627
const { items, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteScrollQuery<Post>({
1728
queryKey: "latest-posts",
1829
fetchFn: posts.latest,
30+
tags: tags,
1931
});
2032

2133
useEffect(() => {
@@ -37,7 +49,33 @@ export default function LatestSection() {
3749

3850
return (
3951
<section className="flex flex-col md:p-4 min-h-[300px]">
40-
<SectionHeader icon={Rss} text="최신 포스트" description="최근에 작성된 포스트" iconColor="text-orange-500" />
52+
<div className="flex items-center gap-3">
53+
<SectionHeader
54+
icon={Rss}
55+
text="최신 포스트"
56+
description="최근에 작성된 포스트"
57+
iconColor="text-orange-500"
58+
secondText="추천 포스트"
59+
secondDescription="사용자 맞춤 추천 포스트"
60+
/>
61+
{pickedFilter.length !== 0 && postType === "latest" && (
62+
<div className=" gap-2 items-center hidden md:flex">
63+
<ul className="flex flex-wrap gap-x-2 gap-y-2">
64+
{pickedFilter.map((filter, index) => (
65+
<Badge key={index} className="hover:bg-primary">
66+
{filter}
67+
</Badge>
68+
))}
69+
</ul>
70+
<button onClick={removeFilter}>
71+
<SquareX size={13} color="red" />
72+
</button>
73+
74+
<span className="text-gray-400 text-xs">카테고리 지정은 최대 5개까지 가능합니다.</span>
75+
</div>
76+
)}
77+
</div>
78+
{postType === "latest" && <Filter />}
4179
<div className="flex-1 mt-4 md:p-6 md:pt-0 rounded-lg">
4280
{isLoading ? (
4381
<PostGridSkeleton count={8} />

client/src/constants/filter.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const CATEGORIES = [
2+
["Frontend", "React", "Vue.JS", "TypeScript", "JavaScript"],
3+
["Backend", "Nest.JS", "Express.JS", "Spring", "Java"],
4+
["회고"],
5+
["MySQL", "SQLite", "PostgreSQL", "MongoDB", "Redis"],
6+
["Docker", "Infra"],
7+
["Network", "OS", "Algorithm"],
8+
];
9+
10+
export const CATEGORIES_KEY = ["FrontEnd", "BackEnd", "회고", "데이터베이스", "인프라", "CS"];
11+
export const CATEGORIES_MAP = {
12+
FrontEnd: 0,
13+
BackEnd: 1,
14+
회고: 2,
15+
데이터베이스: 3,
16+
인프라: 4,
17+
CS: 5,
18+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect, useState } from "react";
2+
3+
export const useRecentTag = () => {
4+
const [recentTag, setRecentTag] = useState<string[]>(() => {
5+
const stored = localStorage.getItem("recent-tag");
6+
return stored ? JSON.parse(stored) : [];
7+
});
8+
9+
useEffect(() => {
10+
const handler = (e: StorageEvent) => {
11+
if (e.key === "recent-tag") {
12+
const newTags = e.newValue ? JSON.parse(e.newValue) : [];
13+
setRecentTag(newTags);
14+
}
15+
};
16+
17+
window.addEventListener("storage", handler);
18+
return () => removeEventListener("storage", handler);
19+
}, []);
20+
21+
return recentTag;
22+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CATEGORIES } from "@/constants/filter";
2+
3+
export const useUpdateRecentTags = (newTags: string[]) => {
4+
const recentTag = localStorage.getItem("recent-tag");
5+
let tags: string[] = recentTag ? JSON.parse(recentTag) : [];
6+
7+
for (const tag of newTags) {
8+
const isValid = CATEGORIES.some((category) => category.includes(tag));
9+
if (!isValid) continue;
10+
11+
tags = [tag, ...tags.filter((t) => t !== tag)];
12+
if (tags.length > 3) tags.pop();
13+
}
14+
15+
localStorage.setItem("recent-tag", JSON.stringify(tags));
16+
};

0 commit comments

Comments
 (0)