-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 인플루언서 추천 피드 - 지현 #284
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
Changes from all commits
569633a
917e1b6
a73b81d
0aa0b56
fdff236
c36bb56
928fd6d
4eb60f4
82319c0
5cbfd82
194c2e8
e67e20f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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'); | ||
| }; | ||
|
|
||
| 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; | ||
| 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; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <EmptyState> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div>피드에 작성된 글이 없어요</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
보안 취약점: window.open에 보안 속성 누락
새 탭에서 링크를 열 때
noopener와noreferrer속성이 없으면 tabnabbing 공격에 취약합니다. 새로 열린 페이지가window.opener를 통해 원본 페이지를 조작할 수 있습니다.다음과 같이 수정하세요:
const handleCardClick = () => { - window.open(`/feed/${postData.feedId}`, '_blank'); + window.open(`/feed/${postData.feedId}`, '_blank', 'noopener,noreferrer'); };또는 React Router의
Link컴포넌트 사용을 고려하세요:🤖 Prompt for AI Agents