Skip to content
Merged
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"@emotion/styled": "^11.14.0",
"@types/react-datepicker": "^7.0.0",
"axios": "^1.11.0",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"react": "^19.1.0",
"react-cookie": "^8.0.1",
"react-datepicker": "^8.4.0",
Expand Down
31 changes: 31 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions src/assets/feed/lookmore-influencer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/components/feed/FeedPost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Container = styled.div`
`;

const BorderBottom = styled.div`
width: 94.8%;
width: 100%;
/* min-width: 280px;
max-width: 500px; */
margin: 0 auto;
Expand Down
65 changes: 65 additions & 0 deletions src/components/feed/RecommendedFeedCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import PostBody from '../common/Post/PostBody';
import PostFooter from '../common/Post/PostFooter';
import PostHeader from '../common/Post/PostHeader';
import type { PostData } from '../../types/post';
import { colors } from '@/styles/global/global';
import lookmoreInfluencer from '@/assets/feed/lookmore-influencer.svg';

interface RecommendedFeedCardProps extends PostData {
aliasColor?: string;
}

const RecommendedFeedCard = (postData: RecommendedFeedCardProps) => {
const handleCardClick = () => {
window.open(`/feed/${postData.feedId}`, '_blank');
};
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

보안 취약점: window.open에 보안 속성 누락

새 탭에서 링크를 열 때 noopenernoreferrer 속성이 없으면 tabnabbing 공격에 취약합니다. 새로 열린 페이지가 window.opener를 통해 원본 페이지를 조작할 수 있습니다.

다음과 같이 수정하세요:

   const handleCardClick = () => {
-    window.open(`/feed/${postData.feedId}`, '_blank');
+    window.open(`/feed/${postData.feedId}`, '_blank', 'noopener,noreferrer');
   };

또는 React Router의 Link 컴포넌트 사용을 고려하세요:

import { Link } from 'react-router-dom';

// In component:
<Link to={`/feed/${postData.feedId}`} target="_blank" rel="noopener noreferrer">
  <CardContainer>
    {/* ... */}
  </CardContainer>
</Link>
🤖 Prompt for AI Agents
In src/components/feed/RecommendedFeedCard.tsx around lines 14-16, the current
window.open call lacks security attributes and is vulnerable to tabnabbing;
modify the click handler to either open the URL via a proper anchor/React Router
Link with target="_blank" and rel="noopener noreferrer", or if you must use
window.open, capture the returned window object and immediately set
newWindow.opener = null and pass "noreferrer" as the third argument to
window.open to prevent the opened page from accessing window.opener and to avoid
leaking the referrer.


return (
<CardContainer onClick={handleCardClick}>
<PostHeader {...postData} aliasName={postData.alias} aliasColor={postData.aliasColor} />
<PostBodyWrapper>
<PostBody {...postData} />
<PostFooter isMyFeed={false} {...postData} />
</PostBodyWrapper>
</CardContainer>
);
};

const CardContainer = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
background-color: ${colors.darkgrey.dark};
border-radius: 12px;
width: 100%;
height: 100%;

> *:last-child {
margin-top: auto;
}

.right {
display: none;
}
`;

const PostBodyWrapper = styled.div`
.content {
-webkit-line-clamp: 3 !important;
min-height: 60px;
}

/* prettier-ignore */
&& img.lookmore-icon,
&& .lookmore-icon {
content: url("${lookmoreInfluencer}") !important;
}

> div > div:first-of-type {
pointer-events: none;
}
`;

export default RecommendedFeedCard;
106 changes: 106 additions & 0 deletions src/components/feed/RecommendedFeedSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import styled from '@emotion/styled';
import RecommendedFeedCard from './RecommendedFeedCard';
import { colors, typography } from '@/styles/global/global';
import useEmblaCarousel from 'embla-carousel-react';
import type { EmblaOptionsType } from 'embla-carousel';
import { allMockRecommendedFeeds } from '@/mocks/recommendedFeeds.mock';


const SectionContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
background-color: ${colors.black.main};
`;

const SectionHeader = styled.div`
padding: 40px 20px 20px;
display: flex;
flex-direction: column;
`;

const HeaderText = styled.h2`
color: ${colors.white};
font-size: ${typography.fontSize.xl};
font-weight: ${typography.fontWeight.bold};
line-height: normal;
margin-bottom: 8px;
`;

const SubText = styled.p`
color: ${colors.grey[100]};
font-size: ${typography.fontSize.sm};
font-weight: ${typography.fontWeight.medium};
line-height: 20px;
margin: 0;
`;

const EmblaViewport = styled.div`
overflow: hidden;
padding: 0 20px;
`;

const EmblaContainer = styled.div`
display: flex;
touch-action: pan-y pinch-zoom;
gap: 12px;
`;

const EmblaSlide = styled.div`
transform: translate3d(0, 0, 0);
flex: 0 0 calc(100% - 10px);
min-width: 0;
padding-bottom: 40px;
`;

const BorderBottom = styled.div`
width: 100%;
margin: 0 auto;
padding: 0 20px;
height: 6px;
background: #1c1c1c;
`;

// Component
interface RecommendedFeedSectionProps {
sectionIndex?: number;
}

const RecommendedFeedSection = ({ sectionIndex = 0 }: RecommendedFeedSectionProps) => {
const options: EmblaOptionsType = {
align: 'center',
slidesToScroll: 1,
containScroll: 'trimSnaps',
};

const [emblaRef] = useEmblaCarousel(options);

// 섹션 인덱스에 따라 다른 5개 게시글 선택
const startIndex = (sectionIndex * 5) % allMockRecommendedFeeds.length;
const selectedFeeds = [
...allMockRecommendedFeeds.slice(startIndex, startIndex + 5),
...allMockRecommendedFeeds.slice(0, Math.max(0, startIndex + 5 - allMockRecommendedFeeds.length)),
].slice(0, 5);

return (
<SectionContainer>
<SectionHeader>
<HeaderText>지금 뜨는 추천 글</HeaderText>
<SubText>비슷한 취향의 인플루언서, 작가가</SubText>
<SubText>추천하는 도서를 만나보세요.</SubText>
</SectionHeader>
<EmblaViewport ref={emblaRef}>
<EmblaContainer>
{selectedFeeds.map(feed => (
<EmblaSlide key={feed.feedId}>
<RecommendedFeedCard {...feed} />
</EmblaSlide>
))}
</EmblaContainer>
</EmblaViewport>
<BorderBottom />
</SectionContainer>
);
};

export default RecommendedFeedSection;
29 changes: 19 additions & 10 deletions src/components/feed/TotalFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import styled from '@emotion/styled';
import FollowList from './FollowList';
import FeedPost from './FeedPost';
import RecommendedFeedSection from './RecommendedFeedSection';
import type { FeedListProps } from '../../types/post';
import { colors, typography } from '@/styles/global/global';

const TotalFeed = ({ showHeader, posts = [], isTotalFeed, isLast = false }: FeedListProps) => {
const TotalFeed = ({ showHeader, posts = [], isTotalFeed }: FeedListProps) => {
const hasPosts = posts.length > 0;

return (
<Container>
<FollowList />
{hasPosts ? (
posts.map((post, index) => (
<FeedPost
key={`${post.feedId}-${index}`}
showHeader={showHeader}
isMyFeed={isTotalFeed}
isLast={isLast && index === posts.length - 1}
{...post}
/>
))
<>
{posts.map((post, index) => (
<>
<FeedPost
key={`${post.feedId}-${index}`}
showHeader={showHeader}
isMyFeed={isTotalFeed}
isLast={false}
{...post}
/>
{/* 10개마다 추천 섹션 반복 표시 */}
{(index + 1) % 10 === 0 && (
<RecommendedFeedSection sectionIndex={Math.floor((index + 1) / 10) - 1} />
)}
</>
))}
Comment on lines +16 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fragment에 key 속성 누락

배열 내에서 Fragment를 사용할 때는 key 속성이 필요합니다. React가 각 항목을 올바르게 식별하지 못하면 콘솔 경고가 발생하고 렌더링 성능에 영향을 줄 수 있습니다.

다음과 같이 수정하세요:

-          {posts.map((post, index) => (
-            <>
+          {posts.map((post, index) => (
+            <React.Fragment key={`${post.feedId}-${index}`}>
               <FeedPost
-                key={`${post.feedId}-${index}`}
                 showHeader={showHeader}
                 isMyFeed={isTotalFeed}
                 isLast={false}
                 {...post}
               />
               {/* 10개마다 추천 섹션 반복 표시 */}
               {(index + 1) % 10 === 0 && (
                 <RecommendedFeedSection sectionIndex={Math.floor((index + 1) / 10) - 1} />
               )}
-            </>
+            </React.Fragment>
           ))}

또한 FeedPost의 key는 Fragment로 이동했으므로 제거하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{posts.map((post, index) => (
<>
<FeedPost
key={`${post.feedId}-${index}`}
showHeader={showHeader}
isMyFeed={isTotalFeed}
isLast={false}
{...post}
/>
{/* 10개마다 추천 섹션 반복 표시 */}
{(index + 1) % 10 === 0 && (
<RecommendedFeedSection sectionIndex={Math.floor((index + 1) / 10) - 1} />
)}
</>
))}
{posts.map((post, index) => (
<React.Fragment key={`${post.feedId}-${index}`}>
<FeedPost
showHeader={showHeader}
isMyFeed={isTotalFeed}
isLast={false}
{...post}
/>
{/* 10개마다 추천 섹션 반복 표시 */}
{(index + 1) % 10 === 0 && (
<RecommendedFeedSection sectionIndex={Math.floor((index + 1) / 10) - 1} />
)}
</React.Fragment>
))}
🤖 Prompt for AI Agents
In src/components/feed/TotalFeed.tsx around lines 16 to 30, the Fragment
wrapping each mapped item is missing a key which causes React warnings; move the
existing key from the FeedPost to the Fragment (e.g. use the same
`${post.feedId}-${index}` value) and remove the key prop from the FeedPost
component so each array child has a unique key on the Fragment instead.

</>
) : (
<EmptyState>
<div>피드에 작성된 글이 없어요</div>
Expand Down
Loading