-
Notifications
You must be signed in to change notification settings - Fork 2
LC-2859 user 마그넷 상세페이지 UI #2151
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
base: LC-2838-Sprint-17
Are you sure you want to change the base?
The head ref may contain hidden characters: "LC-2859-user-\uB9C8\uADF8\uB137-\uC0C1\uC138\uD398\uC774\uC9C0-UI"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,222 @@ | ||||||||||||||||||||||||||
| import { ProgramRecommendItem } from '@/api/blog/blogSchema'; | ||||||||||||||||||||||||||
| import { fetchProgramRecommend } from '@/api/program'; | ||||||||||||||||||||||||||
| import LikeButton from '@/common/button/LikeButton'; | ||||||||||||||||||||||||||
| import ContentCard from '@/common/card/ContentCard'; | ||||||||||||||||||||||||||
| import MoreHeader from '@/common/header/MoreHeader'; | ||||||||||||||||||||||||||
| import HorizontalRule from '@/common/HorizontalRule'; | ||||||||||||||||||||||||||
| import BlogKakaoShareBtn from '@/domain/blog/button/BlogKakaoShareBtn'; | ||||||||||||||||||||||||||
| import BlogLinkShareBtn from '@/domain/blog/button/BlogLilnkShareBtn'; | ||||||||||||||||||||||||||
| import ProgramRecommendCard from '@/domain/blog/card/ProgramRecommendCard'; | ||||||||||||||||||||||||||
| import Heading2 from '@/domain/blog/ui/BlogHeading2'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| findMockLibrary, | ||||||||||||||||||||||||||
| MOCK_LIBRARY_RECOMMENDS, | ||||||||||||||||||||||||||
| } from '@/domain/library/data/mockLibraryData'; | ||||||||||||||||||||||||||
| import LibraryArticle from '@/domain/library/ui/LibraryArticle'; | ||||||||||||||||||||||||||
| import { twMerge } from '@/lib/twMerge'; | ||||||||||||||||||||||||||
| import { ProgramStatusEnum, ProgramTypeEnum } from '@/schema'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| getBaseUrlFromServer, | ||||||||||||||||||||||||||
| getLibraryPathname, | ||||||||||||||||||||||||||
| getLibraryTitle, | ||||||||||||||||||||||||||
| } from '@/utils/url'; | ||||||||||||||||||||||||||
| import { CircleChevronRight } from 'lucide-react'; | ||||||||||||||||||||||||||
| import { Metadata } from 'next'; | ||||||||||||||||||||||||||
| import Link from 'next/link'; | ||||||||||||||||||||||||||
| import { notFound } from 'next/navigation'; | ||||||||||||||||||||||||||
| import { ReactNode } from 'react'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const { CHALLENGE } = ProgramTypeEnum.enum; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export async function generateMetadata({ | ||||||||||||||||||||||||||
| params, | ||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||
| params: Promise<{ id: string }>; | ||||||||||||||||||||||||||
| }): Promise<Metadata> { | ||||||||||||||||||||||||||
| const { id } = await params; | ||||||||||||||||||||||||||
| const library = findMockLibrary(id); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (!library) return {}; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| title: getLibraryTitle(library), | ||||||||||||||||||||||||||
| description: library.description, | ||||||||||||||||||||||||||
| openGraph: { | ||||||||||||||||||||||||||
| title: library.title, | ||||||||||||||||||||||||||
| description: library.description, | ||||||||||||||||||||||||||
| url: getBaseUrlFromServer() + getLibraryPathname(library), | ||||||||||||||||||||||||||
| images: library.thumbnail ? [{ url: library.thumbnail }] : [], | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| alternates: { | ||||||||||||||||||||||||||
| canonical: getBaseUrlFromServer() + getLibraryPathname(library), | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export default async function LibraryDetailPage({ | ||||||||||||||||||||||||||
| params, | ||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||
| params: Promise<{ id: string; title: string }>; | ||||||||||||||||||||||||||
| }) { | ||||||||||||||||||||||||||
| const { id } = await params; | ||||||||||||||||||||||||||
|
Comment on lines
+56
to
+61
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.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const libraryInfo = findMockLibrary(id); | ||||||||||||||||||||||||||
| if (!libraryInfo) notFound(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const programRecommendList = await getProgramRecommendList(); | ||||||||||||||||||||||||||
| const recommendLibraries = MOCK_LIBRARY_RECOMMENDS.filter( | ||||||||||||||||||||||||||
| (item) => item.id !== libraryInfo.id, | ||||||||||||||||||||||||||
| ).slice(0, 4); | ||||||||||||||||||||||||||
|
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.
References
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async function getProgramRecommendList() { | ||||||||||||||||||||||||||
| const data = await fetchProgramRecommend(); | ||||||||||||||||||||||||||
| const list: ProgramRecommendItem[] = []; | ||||||||||||||||||||||||||
| const ctaTitles: Record<string, string> = { | ||||||||||||||||||||||||||
| CAREER_START: '경험 정리부터 이력서 완성까지', | ||||||||||||||||||||||||||
| PERSONAL_STATEMENT: '합격을 만드는 자소서 작성법', | ||||||||||||||||||||||||||
| PORTFOLIO: '나를 돋보이게 하는 포트폴리오', | ||||||||||||||||||||||||||
| PERSONAL_STATEMENT_LARGE_CORP: '합격을 만드는 자소서 작성법', | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (data.challengeList.length > 0) { | ||||||||||||||||||||||||||
| const targets = data.challengeList.slice(0, 3).map((item) => ({ | ||||||||||||||||||||||||||
|
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.
References
|
||||||||||||||||||||||||||
| id: `${CHALLENGE}-${item.id}`, | ||||||||||||||||||||||||||
| ctaLink: `/program/${CHALLENGE.toLowerCase()}/${item.id}`, | ||||||||||||||||||||||||||
| ctaTitle: ctaTitles[item.challengeType ?? 'CAREER_START'], | ||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||
| list.push(...targets); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return list; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <main className="mx-auto w-full max-w-[1100px] pb-12 pt-[60px] md:pb-[7.5rem]"> | ||||||||||||||||||||||||||
| <div className="flex flex-col items-center md:flex-row md:items-start md:gap-20"> | ||||||||||||||||||||||||||
| {/* 본문 */} | ||||||||||||||||||||||||||
| <section className="w-full px-5 md:px-0"> | ||||||||||||||||||||||||||
| <LibraryArticle libraryInfo={libraryInfo} /> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <section className="mb-9 mt-10 flex items-center justify-between md:mb-6"> | ||||||||||||||||||||||||||
| {/* 좋아요 */} | ||||||||||||||||||||||||||
| <LikeButton | ||||||||||||||||||||||||||
| id={id} | ||||||||||||||||||||||||||
| likeCount={0} | ||||||||||||||||||||||||||
| storageKey="library_like" | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| {/* 공유하기 */} | ||||||||||||||||||||||||||
| <div className="flex items-center"> | ||||||||||||||||||||||||||
| <span className="mr-1.5 hidden text-xsmall14 font-medium text-neutral-35 md:block"> | ||||||||||||||||||||||||||
| 나만 보기 아깝다면 공유하기 | ||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||
| <BlogLinkShareBtn | ||||||||||||||||||||||||||
| className="border-none p-2" | ||||||||||||||||||||||||||
| hideCaption | ||||||||||||||||||||||||||
| iconWidth={20} | ||||||||||||||||||||||||||
| iconHeight={20} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| <BlogKakaoShareBtn | ||||||||||||||||||||||||||
| className="p-2" | ||||||||||||||||||||||||||
| title={libraryInfo.title} | ||||||||||||||||||||||||||
| description={libraryInfo.description} | ||||||||||||||||||||||||||
| thumbnail={libraryInfo.thumbnail ?? ''} | ||||||||||||||||||||||||||
| pathname={getLibraryPathname(libraryInfo)} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| <span className="text-xsmall14 font-medium text-neutral-35 md:hidden"> | ||||||||||||||||||||||||||
| 공유하기 | ||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <HorizontalRule className="-mx-5 h-3 md:hidden" /> | ||||||||||||||||||||||||||
| <Link | ||||||||||||||||||||||||||
| href="/library/list" | ||||||||||||||||||||||||||
| className="flex w-full items-center justify-center gap-2 py-5 md:rounded-xs md:bg-neutral-95" | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| <p className="text-xsmall14 font-semibold text-neutral-0 md:text-xsmall16 md:font-medium"> | ||||||||||||||||||||||||||
| <span className="font-semibold text-primary">자료집 홈</span>{' '} | ||||||||||||||||||||||||||
| 바로가기 | ||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||
| <CircleChevronRight | ||||||||||||||||||||||||||
| className="h-4 w-4 md:h-5 md:w-5" | ||||||||||||||||||||||||||
| color="#5F66F6" | ||||||||||||||||||||||||||
|
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. 색상 값 References
|
||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||
| <HorizontalRule className="-mx-5 h-3 md:hidden" /> | ||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| {/* 프로그램 추천 */} | ||||||||||||||||||||||||||
| {programRecommendList.length > 0 && ( | ||||||||||||||||||||||||||
| <aside className="w-full px-5 py-9 md:sticky md:top-[100px] md:max-w-[20.5rem] md:rounded-md md:border md:border-neutral-80 md:px-6 md:py-5"> | ||||||||||||||||||||||||||
| <Heading2 className="text-neutral-0 md:text-xsmall16"> | ||||||||||||||||||||||||||
| 렛츠커리어 프로그램 참여하고 | ||||||||||||||||||||||||||
| <br /> | ||||||||||||||||||||||||||
| 취뽀 성공해요! | ||||||||||||||||||||||||||
| </Heading2> | ||||||||||||||||||||||||||
| <section className="mb-6 mt-5 flex flex-col gap-6"> | ||||||||||||||||||||||||||
| {programRecommendList.map((item) => ( | ||||||||||||||||||||||||||
| <ProgramRecommendCard key={item.id} program={item} /> | ||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||
| <MoreLink | ||||||||||||||||||||||||||
| href={`/program/?status=${ProgramStatusEnum.enum.PROCEEDING}`} | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| 모집 중인 프로그램 보기 | ||||||||||||||||||||||||||
| </MoreLink> | ||||||||||||||||||||||||||
| </aside> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <HorizontalRule className="h-3 md:hidden" /> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| {/* 다른 자료집 추천 */} | ||||||||||||||||||||||||||
| {recommendLibraries.length > 0 && ( | ||||||||||||||||||||||||||
| <section className="px-5 py-9 md:mt-[6.25rem] md:p-0"> | ||||||||||||||||||||||||||
| <MoreHeader | ||||||||||||||||||||||||||
| href="/library/list" | ||||||||||||||||||||||||||
| gaText="다른 취준생들이 함께 찾은 콘텐츠" | ||||||||||||||||||||||||||
| hideMoreWhenMobile | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| 다른 취준생들이 함께 찾은 콘텐츠 | ||||||||||||||||||||||||||
| </MoreHeader> | ||||||||||||||||||||||||||
| <div className="mb-6 mt-5 grid grid-cols-1 gap-6 md:mt-6 md:grid-cols-4 md:items-start md:gap-5"> | ||||||||||||||||||||||||||
| {recommendLibraries.map((lib) => ( | ||||||||||||||||||||||||||
| <ContentCard | ||||||||||||||||||||||||||
| key={lib.id} | ||||||||||||||||||||||||||
| variant="library" | ||||||||||||||||||||||||||
| href={getLibraryPathname(lib)} | ||||||||||||||||||||||||||
| category={lib.category} | ||||||||||||||||||||||||||
| title={lib.title} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
| <MoreLink href="/library/list" className="md:hidden"> | ||||||||||||||||||||||||||
| 더 많은 자료집 보기 | ||||||||||||||||||||||||||
| </MoreLink> | ||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| </main> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function MoreLink({ | ||||||||||||||||||||||||||
| href, | ||||||||||||||||||||||||||
| children, | ||||||||||||||||||||||||||
| className, | ||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||
| href: string; | ||||||||||||||||||||||||||
| children?: ReactNode; | ||||||||||||||||||||||||||
| className?: string; | ||||||||||||||||||||||||||
| }) { | ||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <Link | ||||||||||||||||||||||||||
| href={href} | ||||||||||||||||||||||||||
| className={twMerge( | ||||||||||||||||||||||||||
| 'block w-full rounded-xs border border-neutral-80 px-5 py-3 text-center font-medium text-neutral-20', | ||||||||||||||||||||||||||
| className, | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| {children} | ||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+202
to
+222
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.
References
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| 'use client'; | ||
|
|
||
| import { Heart } from 'lucide-react'; | ||
| import { useEffect, useRef, useState } from 'react'; | ||
|
|
||
| interface LikeButtonProps { | ||
| id: string; | ||
| likeCount: number; | ||
| storageKey?: string; | ||
| onLike?: (id: string) => void; | ||
| onDislike?: (id: string) => void; | ||
| className?: string; | ||
| } | ||
|
|
||
| export default function LikeButton({ | ||
| id, | ||
| likeCount, | ||
| storageKey = 'like', | ||
| onLike, | ||
| onDislike, | ||
| className, | ||
| }: LikeButtonProps) { | ||
| const likedIds = useRef<string[]>([]); | ||
| const [alreadyLike, setAlreadyLike] = useState(false); | ||
| const [countOne, setCountOne] = useState(0); | ||
|
|
||
| useEffect(() => { | ||
| const value = localStorage.getItem(storageKey); | ||
| if (value && id) { | ||
| const list = value.split(','); | ||
| likedIds.current = list; | ||
| setAlreadyLike(list.includes(id)); | ||
| } | ||
| }, [id, storageKey]); | ||
|
|
||
| const handleClick = () => { | ||
| if (alreadyLike) { | ||
| onDislike?.(id); | ||
| setCountOne((prev) => prev - 1); | ||
| setAlreadyLike(false); | ||
| likedIds.current = likedIds.current.filter((i) => i !== id); | ||
| } else { | ||
| onLike?.(id); | ||
| setCountOne((prev) => prev + 1); | ||
| setAlreadyLike(true); | ||
| likedIds.current.push(id); | ||
| } | ||
|
|
||
| localStorage.setItem(storageKey, likedIds.current.toString()); | ||
| }; | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| className={className ?? 'flex items-center gap-2'} | ||
| onClick={handleClick} | ||
| > | ||
| <Heart | ||
| width={20} | ||
| height={20} | ||
| color="#4D55F5" | ||
| fill={alreadyLike ? '#4D55F5' : 'none'} | ||
|
Comment on lines
+61
to
+62
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. 색상 값 References
|
||
| /> | ||
| <span className="text-xsmall14 font-medium text-primary"> | ||
| 좋아요 {likeCount + countOne} | ||
| </span> | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { twMerge } from '@/lib/twMerge'; | ||
| import Link from 'next/link'; | ||
| import { ReactNode } from 'react'; | ||
|
|
||
| interface ContentCardProps { | ||
| href: string; | ||
| target?: string; | ||
| thumbnail?: ReactNode; | ||
| category: string; | ||
| title: string; | ||
| date?: string; | ||
| dateClassName?: string; | ||
| actionButton?: ReactNode; | ||
| variant?: 'blog' | 'library'; | ||
| className?: string; | ||
| containerProps?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export default function ContentCard({ | ||
| href, | ||
| target, | ||
| thumbnail, | ||
| category, | ||
| title, | ||
| date, | ||
| dateClassName, | ||
| actionButton, | ||
| variant = 'blog', | ||
| className, | ||
| containerProps, | ||
| }: ContentCardProps) { | ||
| return ( | ||
| <div | ||
| {...containerProps} | ||
| className={twMerge( | ||
| 'group relative flex flex-col gap-2.5', | ||
| className, | ||
| )} | ||
| > | ||
| <div className="relative aspect-[4/3] w-full overflow-hidden rounded-sm bg-neutral-90"> | ||
| {thumbnail} | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-1"> | ||
| <span | ||
| className={twMerge( | ||
| 'text-xsmall14 font-semibold text-primary', | ||
| variant === 'library' && 'truncate', | ||
| )} | ||
| > | ||
| {category} | ||
| </span> | ||
|
|
||
| <div className="flex flex-col gap-2"> | ||
| <h3 | ||
| className={twMerge( | ||
| 'font-semibold text-neutral-0', | ||
| variant === 'blog' | ||
| ? 'line-clamp-3 text-small18 md:text-xsmall16' | ||
| : 'line-clamp-2 text-xsmall16', | ||
| )} | ||
| > | ||
| <Link href={href} target={target}> | ||
| {title} | ||
| <span className="absolute inset-0" /> | ||
| </Link> | ||
| </h3> | ||
|
|
||
| {(date || actionButton) && ( | ||
| <div className="flex items-center justify-between py-2"> | ||
| {date && ( | ||
| <span | ||
| className={twMerge( | ||
| 'text-xxsmall12 text-neutral-40', | ||
| dateClassName, | ||
| )} | ||
| > | ||
| {date} | ||
| </span> | ||
| )} | ||
| {actionButton} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </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.
generateMetadata함수의params타입이Promise로 잘못 지정되어 있으며,await을 사용하여 값을 가져오고 있습니다. Next.js는params를 Promise가 아닌, 이미 resolve된 객체로 전달하므로Promise래퍼와await키워드를 제거해야 합니다.