diff --git a/package-lock.json b/package-lock.json index d851b26a..3c5fd308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.14.2", + "react-slick": "^0.29.0", + "slick-carousel": "^1.8.1", "uuid": "^9.0.1", "uuidv4": "^6.2.13", "zod": "^3.22.4" @@ -38,6 +40,7 @@ "@types/node": "^20.4.5", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/react-slick": "^0.23.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", @@ -3041,6 +3044,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-slick": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.12.tgz", + "integrity": "sha512-WjY/wIjzgXCh6gXRZL75OC9n/Hn4MwKWI7ZJ4iA2OxavN9eKvkV5MPFjSgH5sofabq78Ucrl6u3okiBUNNIrDQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", @@ -3925,6 +3937,11 @@ "node": "*" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/clsx": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", @@ -4270,6 +4287,11 @@ "node": ">=10.13.0" } }, + "node_modules/enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6282,6 +6304,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6328,6 +6356,14 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6435,6 +6471,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7396,6 +7437,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-slick": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.29.0.tgz", + "integrity": "sha512-TGdOKE+ZkJHHeC4aaoH85m8RnFyWqdqRfAGkhd6dirmATXMZWAxOpTLmw2Ll/jPTQ3eEG7ercFr/sbzdeYCJXA==", + "dependencies": { + "classnames": "^2.2.5", + "enquire.js": "^2.1.6", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -7498,6 +7555,11 @@ "node": ">=0.10.5" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -7839,6 +7901,14 @@ "node": ">=8" } }, + "node_modules/slick-carousel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz", + "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==", + "peerDependencies": { + "jquery": ">=1.8.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -7880,6 +7950,11 @@ "node": ">= 0.4" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, "node_modules/string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", diff --git a/package.json b/package.json index a5be0d26..48648c40 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.14.2", + "react-slick": "^0.29.0", + "slick-carousel": "^1.8.1", "uuid": "^9.0.1", "uuidv4": "^6.2.13", "zod": "^3.22.4" @@ -43,6 +45,7 @@ "@types/node": "^20.4.5", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/react-slick": "^0.23.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", diff --git a/src/features/animes/api/AnimeApi.ts b/src/features/animes/api/AnimeApi.ts index ad040aa3..38bbeee3 100644 --- a/src/features/animes/api/AnimeApi.ts +++ b/src/features/animes/api/AnimeApi.ts @@ -102,19 +102,10 @@ export default class AnimeApi { } async getListOfRecentReviewed(): Promise { - return [ - listOfRecentReviewedMock.at(-1) as getListOfRecentReviewedResponse, - ...listOfRecentReviewedMock, - listOfRecentReviewedMock[0], - ]; + return listOfRecentReviewedMock; + //FIXME: URI 수정 - // 무한 캐러셀을 위한 배열 확장 - // return get(`/someURI`) // - // .then((data) => [ - // data.at(-1) as getListOfRecentReviewedResponse, - // ...data, - // data[0], - // ]); + // return get(`/someURI`); } async getTOP10List(): Promise { diff --git a/src/features/animes/components/AnimeCard/index.tsx b/src/features/animes/components/AnimeCard/index.tsx index e8309b72..368563d8 100644 --- a/src/features/animes/components/AnimeCard/index.tsx +++ b/src/features/animes/components/AnimeCard/index.tsx @@ -24,8 +24,8 @@ export interface AnimeCardProps { /** 애니 평점 */ starScoreAvg: number; - /** UI 사이즈 */ - size?: "md" | "lg"; + /** 페이지 이동 */ + onClick: (animeId: number, e: React.MouseEvent) => void; } export default function AnimeCard({ @@ -33,22 +33,20 @@ export default function AnimeCard({ thumbnail, title, starScoreAvg, - size, + onClick, }: AnimeCardProps) { return ( - - - - - {title} - - - - {starScoreAvg === 0 ? "평가 전" : calcStarRatingAvg(starScoreAvg)} - - - - + onClick(id, e)}> + + + {title} + + + + {starScoreAvg === 0 ? "평가 전" : calcStarRatingAvg(starScoreAvg)} + + + ); } diff --git a/src/features/animes/components/AnimeCard/style.ts b/src/features/animes/components/AnimeCard/style.ts index b80a6cca..1f0db8d7 100644 --- a/src/features/animes/components/AnimeCard/style.ts +++ b/src/features/animes/components/AnimeCard/style.ts @@ -1,28 +1,16 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; -interface CardProps { - size?: "md" | "lg"; -} - -interface ImageProps extends CardProps { +interface ImageProps { image: string; } -export const AnimeCardContainer = styled.div` - width: ${({ size = "md" }) => (size === "md" ? `160px` : `100%`)}; - flex-shrink: 0; - & > a { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - } +export const AnimeCardContainer = styled.div` + width: 100%; `; export const Image = styled.div` - width: 100%; - height: ${({ size = "md" }) => (size === "md" ? `110px` : `152px`)}; + height: 110px; border-radius: 5px; ${({ image }) => css` background: @@ -31,11 +19,11 @@ export const Image = styled.div` `} background-size: cover; background-position: center; + margin-bottom: 8px; `; -export const InfoContainer = styled.div` +export const InfoContainer = styled.div` width: 100%; - height: ${({ size = "md" }) => (size === "md" ? `65px` : `fit-content`)}; display: flex; flex-direction: column; align-items: flex-start; diff --git a/src/features/animes/components/AnimeCarousel/SlideItem.tsx b/src/features/animes/components/AnimeCarousel/SlideItem.tsx index 9ec85aa3..2a854c09 100644 --- a/src/features/animes/components/AnimeCarousel/SlideItem.tsx +++ b/src/features/animes/components/AnimeCarousel/SlideItem.tsx @@ -9,6 +9,10 @@ interface SlideItemProps { anime: getListOfRecentReviewedResponse; } +/** + * depreacted + * SliderItem 컴포넌트로 변경 + * */ export default function SlideItem({ anime }: SlideItemProps) { const navigate = useNavigate(); return ( diff --git a/src/features/animes/components/AnimeCarousel/index.tsx b/src/features/animes/components/AnimeCarousel/index.tsx index 26539e4f..c9615290 100644 --- a/src/features/animes/components/AnimeCarousel/index.tsx +++ b/src/features/animes/components/AnimeCarousel/index.tsx @@ -15,6 +15,10 @@ import { IndicatorContainer, } from "./style"; +/** + * depreacted + * AnimeMainCarousel 컴포넌트로 변경 + * */ export default function AnimeCarousel() { const [currentSlide, setCurrentSlide] = useState(1); // 현재 슬라이드 인덱스 const [translateValue, setTranslateValue] = useState(0); // 슬라이드 이동(translate)를 위해 사용 diff --git a/src/features/animes/components/AnimeMainCarousel/SliderItem.style.ts b/src/features/animes/components/AnimeMainCarousel/SliderItem.style.ts new file mode 100644 index 00000000..b6aeb3a5 --- /dev/null +++ b/src/features/animes/components/AnimeMainCarousel/SliderItem.style.ts @@ -0,0 +1,69 @@ +import styled from "@emotion/styled"; +import { Star } from "@phosphor-icons/react"; + +export const SliderItemContainer = styled.section` + position: relative; + padding: 24px 16px; +`; + +export const Image = styled.img` + width: 100%; + height: 497px; + object-fit: cover; + border-radius: 10px; + background: rgb(140, 140, 140); + background: linear-gradient( + 180deg, + rgba(140, 140, 140, 1) 0%, + rgba(0, 0, 0, 1) 100% + ); +`; + +export const InfoContainer = styled.div` + width: 100%; + position: absolute; + left: 0; + bottom: 46px; + padding: 0 32px 24px; + color: ${({ theme }) => theme.colors.neutral["05"]}; +`; + +export const Title = styled.h5` + width: 100%; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 8px; + ${({ theme }) => theme.typo["title-2-m"]} +`; + +export const ReviewContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const Review = styled.span` + ${({ theme }) => theme.typo["body-3-r"]} + display: block; + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +export const RatingContainer = styled.div` + display: flex; + align-items: center; + color: ${({ theme }) => theme.colors.secondary[50]}; +`; + +export const StarIcon = styled(Star)` + color: ${({ theme }) => theme.colors.secondary[50]}; + margin-right: 4px; +`; + +export const Score = styled.span` + ${({ theme }) => theme.typo["body-2-r"]} +`; diff --git a/src/features/animes/components/AnimeMainCarousel/SliderItem.tsx b/src/features/animes/components/AnimeMainCarousel/SliderItem.tsx new file mode 100644 index 00000000..cd13238f --- /dev/null +++ b/src/features/animes/components/AnimeMainCarousel/SliderItem.tsx @@ -0,0 +1,35 @@ +import { getListOfRecentReviewedResponse } from "../../api/AnimeApi"; + +import { + Image, + InfoContainer, + RatingContainer, + Review, + ReviewContainer, + Score, + SliderItemContainer, + StarIcon, + Title, +} from "./SliderItem.style"; +interface SliderItemProps { + anime: getListOfRecentReviewedResponse; + onClick: (e: React.MouseEvent) => void; +} + +export default function SliderItem({ anime, onClick }: SliderItemProps) { + return ( + + {anime.title} + + {anime.title} + + {anime.review} + + + {anime.avgScore} + + + + + ); +} diff --git a/src/features/animes/components/AnimeMainCarousel/index.tsx b/src/features/animes/components/AnimeMainCarousel/index.tsx new file mode 100644 index 00000000..a0a326da --- /dev/null +++ b/src/features/animes/components/AnimeMainCarousel/index.tsx @@ -0,0 +1,61 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import Slider from "react-slick"; + +import { useApi } from "@/hooks/useApi"; +import { MainCarousel } from "@/libs/carousel"; + +import AnimeCarouselLoading from "../AnimeCarousel/AnimeCarouselLoading"; + +import SliderItem from "./SliderItem"; +import { AnimeMainCarouselContainer } from "./style"; + +export default function AnimeMainCarousel() { + const [currentSlide, setCurrentSlide] = useState(0); + const [dragging, setDragging] = useState(false); + const { animeApi } = useApi(); + const navigate = useNavigate(); + const { data: animes, isLoading } = useQuery({ + queryKey: ["listOfRecentReviewed"], + queryFn: () => animeApi.getListOfRecentReviewed(), + }); + + const handleBeforeChange = (newSlide: number) => { + setDragging(true); + setCurrentSlide(newSlide); + }; + const handleAfterChange = () => setDragging(false); + const handleSliderItemClick = (e: React.MouseEvent, animesId: number) => { + if (dragging) { + e.stopPropagation(); + return; + } + navigate(`animes/${animesId}`); + }; + + if (isLoading) return ; + return ( + <> + {animes && ( + + handleBeforeChange(newSlide)} + afterChange={handleAfterChange} + > + {animes.map((anime, index) => ( + + handleSliderItemClick(e, anime.id) + } + /> + ))} + + + )} + + ); +} diff --git a/src/features/animes/components/AnimeMainCarousel/style.ts b/src/features/animes/components/AnimeMainCarousel/style.ts new file mode 100644 index 00000000..2625efa4 --- /dev/null +++ b/src/features/animes/components/AnimeMainCarousel/style.ts @@ -0,0 +1,71 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; + +export const AnimeMainCarouselContainer = styled.div<{ image: string }>` + position: relative; + width: 100%; + height: 545px; + overflow: hidden; + + ${({ theme }) => theme.mq("md")} { + & .slick-prev, + .slick-next { + z-index: ${({ theme }) => theme.zIndex.carousel}; + } + & .slick-prev { + left: 25px; + } + & .slick-next { + right: 25px; + } + } + + & .slick-dots { + position: absolute; + bottom: 40px; + left: 0; + + li { + margin: 0 2px; + } + + li, + button { + width: 10px; + } + + .slick-active { + button::before { + color: ${({ theme }) => theme.colors.neutral["05"]}; + opacity: 1; + width: 10px; + } + } + + button::before { + color: ${({ theme }) => theme.colors.neutral[50]}; + opacity: 1; + width: 10px; + } + } + + /** 배경 blur 처리 */ + &::before { + content: ""; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + ${({ image }) => css` + background: + linear-gradient(0deg, rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.5) 100%), + url(${image}), + lightgray -36.515px 1.434px / 119.018% 99.743% no-repeat; + `} + background-size: cover; + background-position: center; + filter: blur(5px); + transform: scale(1.05); + } +`; diff --git a/src/features/animes/components/AnimeRanking/index.tsx b/src/features/animes/components/AnimeRanking/index.tsx index debb0f18..0e5ca330 100644 --- a/src/features/animes/components/AnimeRanking/index.tsx +++ b/src/features/animes/components/AnimeRanking/index.tsx @@ -1,18 +1,18 @@ import { Star } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useNavigate } from "react-router-dom"; +import Slider from "react-slick"; import { useApi } from "@/hooks/useApi"; +import { SyncingMainCarousel, SyncingSubCarousel } from "@/libs/carousel"; import AnimeRankingLoading from "./AnimeRankingLoading"; import { HighlightItem, HighlightItemContainer, AnimeRankingContainer, - Content, Rank, - ItemSlider, SliderItem, SliderItemImage, SliderItemRating, @@ -23,54 +23,75 @@ interface AnimeRankingProps { } export default function AnimeRanking({ title }: AnimeRankingProps) { - const navgiate = useNavigate(); - const [currentIndex, setCurrentIndex] = useState(0); + const navigate = useNavigate(); + const [mainNav, setMainNav] = useState(); + const [subNav, setSubNav] = useState(); + const [dragging, setDragging] = useState(false); const { animeApi } = useApi(); const { data: animes, isLoading } = useQuery({ queryKey: ["top10List"], queryFn: () => animeApi.getTOP10List(), }); + const handleClick = (e: React.MouseEvent, animesId: number) => { + if (dragging) { + e.stopPropagation(); + return; + } + navigate(`/animes/${animesId}`); + }; return ( <> + {isLoading && } {animes && ( - -

{title}

- - {isLoading && } - {!isLoading && ( - <> - - navgiate(`/animes/${currentIndex}`)} - > - {animes[currentIndex].rank} -

{animes[currentIndex].genres.join("/")}

-

{animes[currentIndex].title}

- - - {animes[currentIndex].avgScore} - -
-
- - {animes.map((ani, i) => ( - setCurrentIndex(ani.rank - 1)} + <> + +

{title}

+ setMainNav(mainNav ?? undefined)} + asNavFor={subNav} + beforeChange={() => setDragging(true)} + afterChange={() => setDragging(false)} + > + {animes.map((ani, i) => ( + + + handleClick(e, ani.id)} > - - {ani.rank} - -
{ani.title}
-
- ))} -
- - )} -
-
+ {ani.rank} +

{ani.genres.join("/")}

+

{ani.title}

+ + + {ani.avgScore} + + + + + ))} +
+ + setSubNav(subNav ?? undefined)} + > + {animes.map((ani, i) => ( + + + {ani.rank} + +
{ani.title}
+
+ ))} + {/* 마지막 슬라이드 아이템이 조금 짤려서 빈 div 추가 */} +
+ + + )} ); diff --git a/src/features/animes/components/AnimeRanking/style.ts b/src/features/animes/components/AnimeRanking/style.ts index 4c994fd7..3ddbfbb6 100644 --- a/src/features/animes/components/AnimeRanking/style.ts +++ b/src/features/animes/components/AnimeRanking/style.ts @@ -50,29 +50,45 @@ export const Rank = styled.div` `; export const AnimeRankingContainer = styled.section` - width: 100%; - display: inline-flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 8px; - flex-shrink: 0; + overflow: hidden; & > h1 { ${({ theme }) => theme.typo["title-2-m"]} color: ${({ theme }) => theme.colors["neutral"]["100"]}; padding-left: 16px; + margin-bottom: 8px; } -`; -export const Content = styled.div` - display: flex; - width: 100%; - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 16px; - flex-shrink: 0; + /** 캐러셀 item 간격 */ + & .slick-list { + margin-right: -8px; + } + & .slick-slide > div { + margin-right: 8px; + } + + /* 아래쪽 캐러셀의 왼쪽 마진 */ + & .slick-slider:last-child { + & .slick-track .slick-slide:first-child { + margin-left: 16px; + } + } + + /* 이전, 다음 버튼 */ + ${({ theme }) => theme.mq("md")} { + & .slick-prev, + .slick-next { + z-index: ${({ theme }) => theme.zIndex.carousel}; + } + & .slick-prev { + left: 24px; + top: 50px; + } + & .slick-next { + right: 24px; + top: 50px; + } + } `; export const HighlightItemContainer = styled.div` @@ -81,6 +97,7 @@ export const HighlightItemContainer = styled.div` height: 0; padding-bottom: 46%; margin: 0 auto; + margin-bottom: 4px; `; export const HighlightItem = styled.div` @@ -121,22 +138,6 @@ export const HighlightItem = styled.div` } `; -export const ItemSlider = styled.div` - width: 100%; - padding: 0 16px; - height: 139px; - display: flex; - gap: 16px; - overflow-x: scroll; - // 스크롤 안 보이도록 함 - -ms-overflow-style: none; /* 익스플로러 */ - scrollbar-width: none; /* 파이어폭스 */ - - &::-webkit-scrollbar { - display: none; /* 크롬, 사파리, 오페라, 엣지 */ - } -`; - export const SliderItem = styled.div` width: 95px; display: flex; @@ -165,6 +166,7 @@ export const SliderItemImage = styled.div` background-size: cover; background-position: center; position: relative; + margin-bottom: 8px; `; export const SliderItemRating = styled(Rating)` diff --git a/src/features/animes/components/AnimeSlide/index.tsx b/src/features/animes/components/AnimeSlide/index.tsx index d74197cf..942496a5 100644 --- a/src/features/animes/components/AnimeSlide/index.tsx +++ b/src/features/animes/components/AnimeSlide/index.tsx @@ -1,7 +1,13 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import Slider from "react-slick"; + +import { RowCarousel } from "@/libs/carousel"; + import { AnimeSlideResponse } from "../../api/AnimeApi"; import AnimeCard from "../AnimeCard"; -import { CardSlider, AnimeSlideContainer } from "./style"; +import { AnimeSlideContainer } from "./style"; interface AnimeSlideProps { title: string; @@ -9,10 +15,24 @@ interface AnimeSlideProps { } export default function AnimeSlide({ title, animes }: AnimeSlideProps) { + const navigate = useNavigate(); + const [dragging, setDragging] = useState(false); + const handleClick = (animesId: number, e: React.MouseEvent) => { + if (dragging) { + e.stopPropagation(); + return; + } + navigate(`/animes/${animesId}`); + }; + return (

{title}

- + setDragging(true)} + afterChange={() => setDragging(false)} + > {animes.map((anime) => ( ))} - + {/* 캐러셀 레이아웃이 어색해지는 현상 수정 (div 추가) */} +
+ ); } diff --git a/src/features/animes/components/AnimeSlide/style.ts b/src/features/animes/components/AnimeSlide/style.ts index 34722fde..425c0467 100644 --- a/src/features/animes/components/AnimeSlide/style.ts +++ b/src/features/animes/components/AnimeSlide/style.ts @@ -5,23 +5,42 @@ export const AnimeSlideContainer = styled.section` display: flex; flex-direction: column; gap: 8px; + overflow: hidden; + + /* 캐러셀 item 간격 */ + & .slick-list { + margin-right: -8px; + } + & .slick-slide > div { + margin-right: 8px; + } + + /* 캐러셀 왼쪽 마진 */ + & .slick-slider { + & .slick-track .slick-slide:first-child { + margin-left: 16px; + } + } + + /* 이전, 다음 버튼 */ + ${({ theme }) => theme.mq("md")} { + & .slick-prev, + .slick-next { + z-index: ${({ theme }) => theme.zIndex.carousel}; + } + & .slick-prev { + left: 24px; + top: 57px; + } + & .slick-next { + right: 12px; + top: 57px; + } + } + & > h1 { ${({ theme }) => theme.colors["neutral"]["100"]}; ${({ theme }) => theme.typo["title-2-m"]}; padding-left: 16px; } `; - -export const CardSlider = styled.div` - width: 100%; - padding: 0 16px; - display: flex; - gap: 8px; - overflow-x: scroll; - -ms-overflow-style: none; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } -`; diff --git a/src/features/animes/routes/List/index.tsx b/src/features/animes/routes/List/index.tsx index fd3fea5d..231e8ecf 100644 --- a/src/features/animes/routes/List/index.tsx +++ b/src/features/animes/routes/List/index.tsx @@ -1,5 +1,6 @@ import { SlidersHorizontal } from "@phosphor-icons/react"; import { useRef } from "react"; +import { useNavigate } from "react-router"; import { v4 as uuid } from "uuid"; import Button from "@/components/Button"; @@ -55,6 +56,7 @@ export default function AnimeList() { } = useFilterAnimes(); const observeRef = useRef(null); + const navigate = useNavigate(); useIntersectionObserver({ target: observeRef, @@ -105,7 +107,14 @@ export default function AnimeList() { {!animesQuery.isLoading && !animesQuery.isFetching && ( <> {animesQuery.data?.pages.map((item) => ( - + <> + navigate(`/animes/${item.id}`)} + /> + {item.id} + ))}
diff --git a/src/features/animes/routes/Search/SearchedAnimes/index.tsx b/src/features/animes/routes/Search/SearchedAnimes/index.tsx index 3d1c4729..4021b1f2 100644 --- a/src/features/animes/routes/Search/SearchedAnimes/index.tsx +++ b/src/features/animes/routes/Search/SearchedAnimes/index.tsx @@ -1,4 +1,5 @@ import { useRef } from "react"; +import { useNavigate } from "react-router"; import DeferredComponent from "@/components/DeferredComponent"; import Empty from "@/components/Error/Empty"; @@ -12,7 +13,7 @@ import { SearchedAnimesContainer } from "./style"; interface SearchedAnimesProps { isLoading: boolean; - animes: AnimeCardProps[]; + animes: Omit[]; /** 다음 페이지 여부 */ hasNext: boolean; @@ -28,6 +29,7 @@ export default function SearchedAnimes({ onLoadNext, }: SearchedAnimesProps) { const observeRef = useRef(null); + const navigate = useNavigate(); useIntersectionObserver({ target: observeRef, @@ -69,6 +71,7 @@ export default function SearchedAnimes({ thumbnail={anime.thumbnail} title={anime.title} starScoreAvg={anime.starScoreAvg} + onClick={() => navigate(`/animes/${anime.id}`)} /> ))} diff --git a/src/features/animes/routes/Search/SuggestedAnimes/index.tsx b/src/features/animes/routes/Search/SuggestedAnimes/index.tsx index 13c49368..e03dcf10 100644 --- a/src/features/animes/routes/Search/SuggestedAnimes/index.tsx +++ b/src/features/animes/routes/Search/SuggestedAnimes/index.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from "react-router"; + import AnimeCard, { AnimeCardProps, } from "@/features/animes/components/AnimeCard"; @@ -7,7 +9,7 @@ import { SuggestedAnimesContainer } from "./style"; interface SuggestedAnimesProps { isLoading: boolean; - animes: AnimeCardProps[]; + animes: Omit[]; } /** 이런 애니는 어떠세요 */ @@ -15,6 +17,8 @@ export default function SuggestedAnimes({ isLoading, animes, }: SuggestedAnimesProps) { + const navigate = useNavigate(); + if (isLoading) { return ( <> @@ -40,6 +44,7 @@ export default function SuggestedAnimes({ thumbnail={anime.thumbnail} title={anime.title} starScoreAvg={anime.starScoreAvg} + onClick={() => navigate(`/animes/${anime.id}`)} /> ))} diff --git a/src/features/common/routes/Home/index.tsx b/src/features/common/routes/Home/index.tsx index 35f46e68..ede14d02 100644 --- a/src/features/common/routes/Home/index.tsx +++ b/src/features/common/routes/Home/index.tsx @@ -2,7 +2,7 @@ import { useNavigate } from "react-router-dom"; import Button from "@/components/Button"; import Head from "@/components/Head"; -import AnimeCarousel from "@/features/animes/components/AnimeCarousel"; +import AnimeMainCarousel from "@/features/animes/components/AnimeMainCarousel"; import AnimeRanking from "@/features/animes/components/AnimeRanking"; import useAuth from "@/features/auth/hooks/useAuth"; @@ -24,7 +24,7 @@ export default function Home() { <> - + diff --git a/src/libs/carousel/index.ts b/src/libs/carousel/index.ts new file mode 100644 index 00000000..e161179c --- /dev/null +++ b/src/libs/carousel/index.ts @@ -0,0 +1,87 @@ +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; + +/** @link https://github.com/akiran/react-slick */ + +/** 최상단 캐러셀 */ +export const MainCarousel = { + dots: true, + infinite: true, + speed: 500, + autoplay: true, + autoplaySpeed: 5000, + slidesToShow: 1, + slidesToScroll: 1, + touchThreshold: 300, + pauseOnDotsHover: true, +}; + +/** Syncing-: 이번주 TOP10 캐러셀 */ +export const SyncingMainCarousel = { + slidesToShow: 1, + slidesToScroll: 1, + arrows: false, + fade: true, + touchThreshold: 300, + autoplay: true, + autoplaySpeed: 5000, +}; + +export const SyncingSubCarousel = { + slidesToShow: 5, + slidesToScroll: 5, + focusOnSelect: true, + touchThreshold: 1000, + infinite: false, + responsive: [ + { + breakpoint: 576, + settings: { + slidesToShow: 3, + slidesToScroll: 3, + speed: 100, + }, + }, + { + breakpoint: 768, + settings: { + slidesToShow: 4, + slidesToScroll: 4, + speed: 100, + }, + }, + ], +}; + +export const RowCarousel = { + slidesToShow: 4, + slidesToScroll: 4, + touchThreshold: 1000, + infinite: false, + responsive: [ + { + breakpoint: 420, + settings: { + slidesToShow: 2, + slidesToScroll: 2, + speed: 100, + }, + }, + { + breakpoint: 576, + settings: { + slidesToShow: 3, + slidesToScroll: 3, + speed: 100, + }, + }, + { + breakpoint: 768, + settings: { + slidesToShow: 3, + slidesToScroll: 3, + speed: 100, + }, + }, + ], +};