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

구경가기 상단 캐러셀 구현 #849

Merged
merged 5 commits into from
Oct 22, 2024
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
5 changes: 5 additions & 0 deletions frontend/src/api/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const getTemplateExplore = async ({
page = 1,
size = PAGE_SIZE,
keyword,
tagIds,
}: TemplateListRequest): Promise<TemplateListResponse> => {
const queryParams = new URLSearchParams({
sort,
Expand All @@ -95,6 +96,10 @@ export const getTemplateExplore = async ({
queryParams.append('keyword', keyword);
}

if (tagIds?.length !== 0 && tagIds !== undefined) {
queryParams.append('tagIds', tagIds.toString());
}

const response = await apiClient.get(`${END_POINTS.TEMPLATES_EXPLORE}?${queryParams.toString()}`);
const data = response.json();

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/assets/images/clock.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 frontend/src/assets/images/person.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions frontend/src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ContactUs, Text } from '@/components';
import { Text } from '@/components';
import { useAuth } from '@/hooks/authentication';

import * as S from './Footer.style';
Expand All @@ -20,7 +20,7 @@ const Footer = () => {
© All rights reserved.
</Text.Small>
<S.ContactEmail>
<ContactUs />
<Text.Small color='inherit'>문의: [email protected]</Text.Small>
</S.ContactEmail>
</S.FooterContainer>
);
Expand Down
14 changes: 5 additions & 9 deletions frontend/src/components/TemplateCard/TemplateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,17 @@ const TemplateCard = ({ template }: Props) => {
<Flex width='100%' direction='column' gap='1rem'>
<Flex width='100%' justify='space-between' gap='1rem'>
<Flex gap='0.75rem' flex='1' style={{ minWidth: '0' }}>
{isPrivate && <PrivateIcon width={ICON_SIZE.X_SMALL} color={theme.color.light.secondary_800} />}
{isPrivate && <PrivateIcon width={ICON_SIZE.X_SMALL} color={theme.color.light.secondary_600} />}
<Flex align='center' gap='0.25rem' style={{ minWidth: '0' }}>
<PersonIcon width={ICON_SIZE.X_SMALL} />
<PersonIcon color={theme.color.light.primary_500} />
<S.EllipsisTextWrapper style={{ width: '100%' }}>
<Text.Small
color={theme.mode === 'dark' ? theme.color.dark.primary_300 : theme.color.light.primary_500}
>
{member.name}
</Text.Small>
<Text.Small color={theme.color.light.primary_500}>{member.name}</Text.Small>
</S.EllipsisTextWrapper>
</Flex>
<Flex align='center' gap='0.25rem'>
<ClockIcon width={ICON_SIZE.X_SMALL} />
<ClockIcon width={ICON_SIZE.X_SMALL} color={theme.color.light.secondary_600} />
<S.NoWrapTextWrapper>
<Text.Small color={theme.color.light.primary_500}>{formatRelativeTime(modifiedAt)}</Text.Small>
<Text.Small color={theme.color.light.secondary_600}>{formatRelativeTime(modifiedAt)}</Text.Small>
</S.NoWrapTextWrapper>
</Flex>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const SearchInput = styled(Input)`
box-shadow: inset 1px 2px 8px #00000030;
`;

export const TemplateListSectionWrapper = styled.div`
position: relative;
width: 100%;
`;

export const ScrollTopButton = styled.button`
cursor: pointer;

Expand Down
52 changes: 39 additions & 13 deletions frontend/src/pages/TemplateExplorePage/TemplateExplorePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { Link } from 'react-router-dom';

import { DEFAULT_SORTING_OPTION, SORTING_OPTIONS } from '@/api';
import { ArrowUpIcon, SearchIcon, ZapzapLogo } from '@/assets/images';
import { ArrowUpIcon, SearchIcon } from '@/assets/images';
import {
Dropdown,
Flex,
Expand All @@ -21,6 +21,9 @@ import { useTemplateExploreQuery } from '@/queries/templates';
import { SortingOption } from '@/types';
import { scroll } from '@/utils';

import { HotTopicCarousel } from './components';
import { useHotTopic } from './hooks';
import { TemplateListSectionLoading } from '../MyTemplatesPage/components';
import * as S from './TemplateExplorePage.style';

const getGridCols = (windowWidth: number) => (windowWidth <= 1024 ? 1 : 2);
Expand All @@ -29,6 +32,8 @@ const TemplateExplorePage = () => {
const [page, setPage] = useState<number>(1);
const [keyword, handleKeywordChange] = useInput('');

const { selectedTagIds, selectedHotTopic, selectTopic } = useHotTopic();

const { currentValue: sortingOption, ...dropdownProps } = useDropdown(DEFAULT_SORTING_OPTION);

const handleSearchSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => {
Expand All @@ -39,9 +44,11 @@ const TemplateExplorePage = () => {

return (
<Flex direction='column' gap='4rem' align='flex-start' css={{ paddingTop: '5rem' }}>
<Flex justify='flex-start' align='center' gap='1rem'>
<ZapzapLogo width={50} height={50} />
<Heading.Medium color='black'>여러 템플릿을 구경해보세요:)</Heading.Medium>
<Flex direction='column' justify='flex-start' gap='1rem' width='100%'>
<Heading.Medium color='black'>
🔥 지금 인기있는 토픽 {selectedHotTopic && `- ${selectedHotTopic} 보는 중`}
</Heading.Medium>
<HotTopicCarousel selectTopic={selectTopic} selectedHotTopic={selectedHotTopic} />
</Flex>

<Flex width='100%' gap='1rem'>
Expand Down Expand Up @@ -70,7 +77,13 @@ const TemplateExplorePage = () => {
onReset={reset}
resetKeys={[keyword]}
>
<TemplateList page={page} setPage={setPage} sortingOption={sortingOption} keyword={keyword} />
<TemplateList
page={page}
setPage={setPage}
sortingOption={sortingOption}
keyword={keyword}
tagIds={selectedTagIds}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Expand All @@ -93,18 +106,26 @@ const TemplateList = ({
setPage,
sortingOption,
keyword,
tagIds,
}: {
page: number;
setPage: (page: number) => void;
sortingOption: SortingOption;
keyword: string;
tagIds: number[];
}) => {
const debouncedKeyword = useDebounce(keyword, 300);

const { data: templateData, isPending } = useTemplateExploreQuery({
const {
data: templateData,
isPending,
isFetching,
isLoading,
} = useTemplateExploreQuery({
sort: sortingOption.key,
page,
keyword: debouncedKeyword,
tagIds,
});
const templateList = templateData?.templates || [];
const totalPages = templateData?.totalPages || 0;
Expand All @@ -125,13 +146,18 @@ const TemplateList = ({
<NoSearchResults />
)
) : (
<S.TemplateExplorePageContainer cols={getGridCols(windowWidth)}>
{templateList.map((template) => (
<Link to={`/templates/${template.id}`} key={template.id}>
<TemplateCard template={template} />
</Link>
))}
</S.TemplateExplorePageContainer>
<S.TemplateListSectionWrapper>
{isFetching && <TemplateListSectionLoading />}
{!isLoading && (
<S.TemplateExplorePageContainer cols={getGridCols(windowWidth)}>
{templateList.map((template) => (
<Link to={`/templates/${template.id}`} key={template.id}>
<TemplateCard template={template} />
</Link>
))}
</S.TemplateExplorePageContainer>
)}
</S.TemplateListSectionWrapper>
)}

{templateList.length !== 0 && (
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import styled from '@emotion/styled';

import { ChevronIcon } from '@/assets/images';
import { theme } from '@/style/theme';

export const CarouselContainer = styled.div`
display: flex;
gap: 1rem;
align-items: center;

width: 100%;
padding: 1rem;
`;

export const CarouselViewport = styled.div`
position: relative;
overflow: hidden;
width: 100%;
`;

export const CarouselList = styled.ul<{ translateX: number; transitioning: boolean }>`
transform: translateX(${(props) => props.translateX}px);
display: flex;
gap: 1rem;
transition: ${(props) => (props.transitioning ? 'transform 0.3s ease-in-out' : 'none')};
`;

export const CarouselItem = styled.li`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;

width: 18rem;
height: 9rem;

@media (max-width: 768px) {
width: 9rem;
}
`;

export const CarouselButton = styled.button`
cursor: pointer;

padding: 1rem;

background-color: white;
border: 1px solid ${theme.color.light.secondary_300};
border-radius: 8px;

&:hover {
background-color: ${theme.color.light.secondary_50};
}
`;

export const PrevIcon = styled(ChevronIcon)`
transform: rotate(90deg);
`;

export const NextIcon = styled(ChevronIcon)`
transform: rotate(270deg);
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useState, useCallback, useEffect, useRef } from 'react';

import { useWindowWidth } from '@/hooks';
import { BREAKING_POINT } from '@/style/styleConstants';

import * as S from './Carousel.style';

interface CarouselItem {
id: number;
content: React.ReactNode;
}

interface Props {
items: CarouselItem[];
}

const Carousel = ({ items }: Props) => {
const windowWidth = useWindowWidth();
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const transitionTimer = useRef<NodeJS.Timeout | null>(null);

const displayItems = [...items, ...items, ...items];

const ITEM_WIDTH = windowWidth < BREAKING_POINT.MOBILE ? 144 : 288; // CarouselItem 넓이
const ITEM_GAP = 16; // 1rem
Comment on lines +25 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 부분은 스타일의 크기와 밀접한 관련이 있기 때문에 상수 처리를 해주거나, 해당 스타일과 묶어주고 싶었으나 현재 좋은 방법이 떠오르지 않아 일단 매직넘버로 처리하고 주석 달아두었습니다.

const MOVE_DISTANCE = ITEM_WIDTH + ITEM_GAP;
const originalLength = items.length;

const translateX = -(currentIndex * MOVE_DISTANCE + originalLength * MOVE_DISTANCE);

const moveCarousel = useCallback(
(direction: 'prev' | 'next') => {
const newIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;

setIsTransitioning(true);
setCurrentIndex(newIndex);
},
[currentIndex],
);

const needsReposition = useCallback(
(index: number) => {
const threshold = originalLength;

return index <= -threshold || index >= threshold;
},
[originalLength],
);

const resetPosition = useCallback(() => {
setIsTransitioning(false);

setCurrentIndex(currentIndex % originalLength);

requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsTransitioning(true);
});
});
}, [currentIndex, originalLength]);

useEffect(() => {
if (needsReposition(currentIndex)) {
if (transitionTimer.current) {
clearTimeout(transitionTimer.current);
}

transitionTimer.current = setTimeout(() => {
resetPosition();
setIsTransitioning(false);
}, 300); // transition: transform 0.3s 이기 때문
}

return () => {
if (transitionTimer.current) {
clearTimeout(transitionTimer.current);
}
};
}, [currentIndex, needsReposition, resetPosition]);

return (
<S.CarouselContainer>
<S.CarouselButton onClick={() => moveCarousel('prev')}>
<S.PrevIcon />
</S.CarouselButton>
<S.CarouselViewport>
<S.CarouselList translateX={translateX} transitioning={isTransitioning}>
{displayItems.map((item) => (
<S.CarouselItem key={item.id}>{item.content}</S.CarouselItem>
))}
</S.CarouselList>
</S.CarouselViewport>
<S.CarouselButton onClick={() => moveCarousel('next')}>
<S.NextIcon />
</S.CarouselButton>
</S.CarouselContainer>
);
};

export default Carousel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import styled from '@emotion/styled';

export const Topic = styled.button<{ background: string; border: string; isSelected: boolean }>`
cursor: pointer; /* 커서 스타일 추가 */

width: 100%;
height: 100%;

background-color: ${({ background }) => background};
border: ${({ isSelected, border }) => (isSelected ? `2px solid ${border}` : 'none')};
border-radius: 12px;

&:hover {
border: 2px solid ${({ border }) => border};
}
`;
Loading