Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: 최신포스트 자동 업데이트 및 NEW태그 추가 #318

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion client/src/api/services/posts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { axiosInstance } from "@/api/instance";
import { InfiniteScrollResponse, LatestPostsApiResponse, Post } from "@/types/post";
import { InfiniteScrollResponse, LatestPostsApiResponse, Post, UpdatePostsApiResponse } from "@/types/post";

export const posts = {
latest: async (params: { limit: number; lastId: number }): Promise<InfiniteScrollResponse<Post>> => {
Expand All @@ -15,4 +15,11 @@ export const posts = {
lastId: response.data.data.lastId,
};
},
update: async (): Promise<UpdatePostsApiResponse> => {
const response = await axiosInstance.get<UpdatePostsApiResponse>("/api/feed/recent");
return {
Comment on lines +18 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4: 전체적으로 axios동작을 관리하는 부분의 코드들에 API 자원 경로 상수들이 문자열 하드코딩 되어있는데, 이 부분을 별도 상수로 분리해 보는게 문자열에 덜 의존적일 것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

따로 상수처리해서 하나의 파일에서 관리하는게 효율적이게 보이긴 하네요! 준혁님이랑 이야기해보고 수정해보겠습니다.

message: response.data.message,
data: response.data.data,
};
},
};
3 changes: 1 addition & 2 deletions client/src/components/common/Card/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ interface PostCardProps {

export const PostCard = ({ post, className }: PostCardProps) => {
const { handlePostClick } = usePostCardActions(post);

return (
<Card
onClick={handlePostClick}
Expand All @@ -23,7 +22,7 @@ export const PostCard = ({ post, className }: PostCardProps) => {
className
)}
>
<PostCardImage thumbnail={post.thumbnail} alt={post.title} />
<PostCardImage thumbnail={post.thumbnail} alt={post.title} isNew={post.isNew} />
<PostCardContent post={post} />
</Card>
);
Expand Down
8 changes: 7 additions & 1 deletion client/src/components/common/Card/PostCardImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import { LazyImage } from "@/components/common/LazyImage";

interface PostCardImageProps {
thumbnail?: string;
isNew?: boolean;
alt: string;
}

export const PostCardImage = ({ thumbnail, alt }: PostCardImageProps) => {
export const PostCardImage = ({ thumbnail, alt, isNew }: PostCardImageProps) => {
return (
<div
className="h-[120px] relative bg-muted flex items-center justify-center overflow-hidden rounded-t-xl"
data-testid="image-container"
>
{isNew && (
<span className="absolute z-10 top-3 right-3 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded animate-pulse">
New
</span>
)}
{thumbnail ? (
<LazyImage
src={thumbnail}
Expand Down
18 changes: 10 additions & 8 deletions client/src/components/common/SectionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ interface SectionHeaderProps {

export const SectionHeader = ({ icon: Icon, text, iconColor, description }: SectionHeaderProps) => {
return (
<div className="flex items-center gap-2">
{Icon && (
<div>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
)}
<h2 className="text-xl font-semibold">{text}</h2>
<p className="text-sm text-gray-400 mt-1">{description}</p>
<div className="flex justify-between">
<div className="flex items-center gap-2">
{Icon && (
<div>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
)}
<h2 className="text-xl font-semibold">{text}</h2>
<p className="text-sm text-gray-400 mt-1">{description}</p>
</div>
</div>
);
};
6 changes: 5 additions & 1 deletion client/src/components/sections/LatestSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Rss } from "lucide-react";
import { PostCardGrid } from "@/components/common/Card/PostCardGrid";
import { PostGridSkeleton } from "@/components/common/Card/PostCardSkeleton.tsx";
import { SectionHeader } from "@/components/common/SectionHeader";
import LatestSectionTimer from "@/components/sections/LatestSectionTimer";

import { useInfiniteScrollQuery } from "@/hooks/queries/useInfiniteScrollQuery";

Expand Down Expand Up @@ -37,7 +38,10 @@ export default function LatestSection() {

return (
<section className="flex flex-col p-4 min-h-[300px]">
<SectionHeader icon={Rss} text="최신 포스트" description="최근에 작성된 포스트" iconColor="text-orange-500" />
<div className="flex justify-between">
<SectionHeader icon={Rss} text="최신 포스트" description="최근에 작성된 포스트" iconColor="text-orange-500" />
<LatestSectionTimer />
</div>
<div className="flex-1 mt-4 p-4 rounded-lg">
{isLoading ? (
<PostGridSkeleton count={8} />
Expand Down
93 changes: 93 additions & 0 deletions client/src/components/sections/LatestSectionTimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";

import { RotateCw } from "lucide-react";

import { useUpdatePost } from "@/hooks/queries/useUpdatePost";

import { UpdatePostsApiResponse } from "@/types/post";
import { Post } from "@/types/post";
import { useQueryClient } from "@tanstack/react-query";

export default function LatestSectionTimer() {
const queryClient = useQueryClient();
const update = useUpdatePost();
const [timer, setTimer] = useState<number>(0);

const calculateTime = () => {
const now = new Date();
const currentMinutes = now.getMinutes();

const targetMinutes = currentMinutes < 31 ? 31 : 1;
let targetHours = now.getHours();

if (currentMinutes >= 31) {
targetHours = (targetHours + 1) % 24;
}

const targetTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), targetHours, targetMinutes, 0);
const remainingTime = Math.floor((targetTime.getTime() - now.getTime()) / 1000);

return remainingTime;
};

useEffect(() => {
setTimer(calculateTime());
const interval = setInterval(() => {
const time = calculateTime();
setTimer(time);
if (time === 0) {
handleUpdate();
setTimer(calculateTime());
}
}, 1000);
return () => clearInterval(interval);
}, []);

const handleUpdate = async () => {
const oldPosts = queryClient.getQueryData<{
pageParams: number;
pages: { hasMore: boolean; lastId: string; result: Post[] }[];
}>(["latest-posts"]);
if (update.data && oldPosts) {
oldPosts.pages.forEach((oldPost) =>
oldPost.result.forEach((post) => {
post.isNew = false;
})
);
queryClient.setQueryData(["latest-posts"], {
...oldPosts,
pages: [
{
...oldPosts.pages[0],
result: [...update.data.data, ...oldPosts.pages[0].result],
},
],
});
}
};
Comment on lines +46 to +67
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4: 51~56번 라인에서는 React Query를 통해 가져온 oldPosts 데이터들에 isNew 필드를 직접 변경하고 있는데요.

React Query의 데이터는 기본적으로 불변성을 지키도록 권장된다고 합니다. 불변성을 지키지 않는다면 처음에는 마치 정상적으로 동작하는 듯 해 보여도, 참조가 동일한곳에 머물러 있기 때문에 더이상 리액트 쿼리가 옵저버들에게 변경사항을 알릴 수 없다고 합니다. - 참고

그리하여 기존 oldPosts 객체를 수정하지 않고, 새로운 객체를 생성해 업데이트 하는 방식을 사용하는 것이 좋을 것 같은데, 혹시 제가 잘못 이해한부분이 있으면 알려주시면 감사하겠습니다 🙇

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 코드를 작성할때 불변성에 대해서는 크게 고민을 안했던거 같습니다. getQuery로 데이터를 불러오고 반환값은 새로운 배열을 만들어서 반환하기 때문에 크게 의미가 있을까 하는 생각에 forEach를 이용해서 isNew를 false로 바꿨었는데 지금보니 forEach보다는 map을 사용해서 새로운 배열을 만들고 데이터를 바꿔주는게 더 괜찮아 보이네요 찾아주셔서 감사합니다!


return <Timer time={timer} handleUpdate={handleUpdate} update={update} />;
}

function Timer({
time,
handleUpdate,
update,
}: {
time: number;
handleUpdate: () => void;
update: { data?: UpdatePostsApiResponse; isLoading: boolean; error: Error | null };
}) {
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")} 초`;
};

return (
<span className="text-sm text-gray-500 bg-gray-50 p-2 rounded-lg mr-5">
{update.error && <button onClick={handleUpdate}>reload</button>}
{update.isLoading ? <RotateCw /> : <span>{formatTime(time)} 후 업데이트</span>}
</span>
);
}
7 changes: 7 additions & 0 deletions client/src/hooks/queries/useUpdatePost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { posts } from "@/api/services/posts";
import { useQuery } from "@tanstack/react-query";

export const useUpdatePost = () => {
const { data, isLoading, error } = useQuery({ queryKey: ["update-posts"], queryFn: posts.update, retry: 1 });
return { data, isLoading, error };
};
Comment on lines +4 to +7
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P5: posts.update@/api/services/posts에 선언된 axios 동작 함수를 호출하는것인 것 같은데, queryKey는 무슨 역할인지 궁금해요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리액트 쿼리에서 API요청에 대한 데이터 식별을 위한 변수랑 비슷한 역할로 쿼리키를 사용합니다. 다른 코드에서 getQuery를 사용하거나 데이터 캐싱에 사용됩니다!

6 changes: 6 additions & 0 deletions client/src/types/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Post {
authorImageUrl?: string;
tags?: string[];
likes?: number;
isNew?: boolean;
blogPlatform?: string;
}

Expand All @@ -31,3 +32,8 @@ export interface InfiniteScrollResponse<T> {
hasMore: boolean;
lastId: number | null;
}

export interface UpdatePostsApiResponse {
message: string;
data: Post[];
}
Loading