diff --git a/frontend/src/assets/images/topic_algorithm.jpg b/frontend/src/assets/images/topic_algorithm.jpg new file mode 100644 index 00000000..0cfbb2fb Binary files /dev/null and b/frontend/src/assets/images/topic_algorithm.jpg differ diff --git a/frontend/src/assets/images/topic_android.jpg b/frontend/src/assets/images/topic_android.jpg new file mode 100644 index 00000000..d8d95b5e Binary files /dev/null and b/frontend/src/assets/images/topic_android.jpg differ diff --git a/frontend/src/assets/images/topic_java.jpg b/frontend/src/assets/images/topic_java.jpg new file mode 100644 index 00000000..614e54ec Binary files /dev/null and b/frontend/src/assets/images/topic_java.jpg differ diff --git a/frontend/src/assets/images/topic_js.jpg b/frontend/src/assets/images/topic_js.jpg new file mode 100644 index 00000000..f65bca6b Binary files /dev/null and b/frontend/src/assets/images/topic_js.jpg differ diff --git a/frontend/src/assets/images/topic_kt.jpg b/frontend/src/assets/images/topic_kt.jpg new file mode 100644 index 00000000..a0b552e5 Binary files /dev/null and b/frontend/src/assets/images/topic_kt.jpg differ diff --git a/frontend/src/assets/images/topic_precourse.jpg b/frontend/src/assets/images/topic_precourse.jpg new file mode 100644 index 00000000..3473bd9c Binary files /dev/null and b/frontend/src/assets/images/topic_precourse.jpg differ diff --git a/frontend/src/assets/images/topic_react.jpg b/frontend/src/assets/images/topic_react.jpg new file mode 100644 index 00000000..fb2452f4 Binary files /dev/null and b/frontend/src/assets/images/topic_react.jpg differ diff --git a/frontend/src/assets/images/topic_spring.jpg b/frontend/src/assets/images/topic_spring.jpg new file mode 100644 index 00000000..f80316d8 Binary files /dev/null and b/frontend/src/assets/images/topic_spring.jpg differ diff --git a/frontend/src/assets/images/topic_wooteco.jpg b/frontend/src/assets/images/topic_wooteco.jpg new file mode 100644 index 00000000..3b6933a2 Binary files /dev/null and b/frontend/src/assets/images/topic_wooteco.jpg differ diff --git a/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts index 877ee188..ec3ecb36 100644 --- a/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts +++ b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts @@ -62,7 +62,7 @@ export const CopyButton = styled(Button)` `; export const SourceCodeWrapper = styled.div<{ isOpen: boolean }>` - overflow: hidden; + overflow: scroll; max-height: ${({ isOpen }) => (isOpen ? '1000rem' : '0')}; animation: ${({ isOpen }) => (!isOpen ? 'collapse' : 'expand')} 0.7s ease-in-out forwards; `; diff --git a/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts b/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts index 746fe199..628948d2 100644 --- a/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts +++ b/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; import { useDropdown, useQueryParams } from '@/hooks'; import { useAuth } from '@/hooks/authentication'; @@ -16,11 +16,11 @@ interface Props { export const useFilteredTemplateList = ({ memberId: passedMemberId }: Props) => { const { queryParams, updateQueryParams } = useQueryParams(); - const [selectedCategoryId, setSelectedCategoryId] = useState(queryParams.category); - const [selectedTagIds, setSelectedTagIds] = useState(queryParams.tags); - const { keyword, debouncedKeyword, handleKeywordChange } = useSearchKeyword(queryParams.keyword); + const selectedCategoryId = queryParams.category; + const selectedTagIds = queryParams.tags; + const page = queryParams.page; const { currentValue: sortingOption, ...dropdownProps } = useDropdown(getSortingOptionByValue(queryParams.sort)); - const [page, setPage] = useState(queryParams.page); + const { keyword, debouncedKeyword, handleKeywordChange } = useSearchKeyword(queryParams.keyword); const { memberInfo } = useAuth(); const memberId = passedMemberId ?? memberInfo.memberId; @@ -42,39 +42,44 @@ export const useFilteredTemplateList = ({ memberId: passedMemberId }: Props) => const totalPages = templateData?.totalPages || 0; useEffect(() => { - updateQueryParams({ keyword: debouncedKeyword, sort: sortingOption.value, page }); - }, [debouncedKeyword, sortingOption, page, updateQueryParams]); + if (queryParams.sort === sortingOption.value) { + return; + } + + updateQueryParams({ sort: sortingOption.value, page: FIRST_PAGE }); + }, [queryParams.sort, sortingOption, updateQueryParams]); + + useEffect(() => { + if (queryParams.keyword === debouncedKeyword) { + return; + } + + updateQueryParams({ keyword: debouncedKeyword, page: FIRST_PAGE }); + }, [queryParams.keyword, debouncedKeyword, updateQueryParams]); const handlePageChange = (page: number) => { scroll.top('smooth'); - setPage(page); + updateQueryParams({ page }); }; const handleCategoryMenuClick = useCallback( (selectedCategoryId: number) => { - updateQueryParams({ category: selectedCategoryId }); - - setSelectedCategoryId(selectedCategoryId); - - handlePageChange(FIRST_PAGE); + updateQueryParams({ category: selectedCategoryId, page: FIRST_PAGE }); }, [updateQueryParams], ); const handleTagMenuClick = useCallback( (selectedTagIds: number[]) => { - updateQueryParams({ tags: selectedTagIds }); - - setSelectedTagIds(selectedTagIds); - handlePageChange(FIRST_PAGE); + updateQueryParams({ tags: selectedTagIds, page: FIRST_PAGE }); }, [updateQueryParams], ); const handleSearchSubmit = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - handlePageChange(FIRST_PAGE); + updateQueryParams({ page: FIRST_PAGE }); } }; diff --git a/frontend/src/pages/TemplateExplorePage/TemplateExplorePage.tsx b/frontend/src/pages/TemplateExplorePage/TemplateExplorePage.tsx index 09fdb06d..9c23b34d 100644 --- a/frontend/src/pages/TemplateExplorePage/TemplateExplorePage.tsx +++ b/frontend/src/pages/TemplateExplorePage/TemplateExplorePage.tsx @@ -1,5 +1,5 @@ import { QueryErrorResetBoundary } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Link } from 'react-router-dom'; @@ -28,6 +28,8 @@ import { useHotTopic } from './hooks'; import { TemplateListSectionLoading } from '../MyTemplatesPage/components'; import * as S from './TemplateExplorePage.style'; +const FIRST_PAGE = 1; + const getGridCols = (windowWidth: number) => (windowWidth <= 1024 ? 1 : 2); const TemplateExplorePage = () => { @@ -35,7 +37,12 @@ const TemplateExplorePage = () => { const { queryParams, updateQueryParams } = useQueryParams(); - const [page, setPage] = useState(queryParams.page); + const page = queryParams.page; + + const handlePage = (page: number) => { + updateQueryParams({ page }); + }; + const [keyword, handleKeywordChange] = useInput(queryParams.keyword); const debouncedKeyword = useDebounce(keyword, 300); @@ -45,12 +52,24 @@ const TemplateExplorePage = () => { const { selectedTagIds, selectedHotTopic, selectTopic } = useHotTopic(); useEffect(() => { - updateQueryParams({ keyword: debouncedKeyword, sort: sortingOption.value, page }); - }, [debouncedKeyword, sortingOption, page, updateQueryParams]); + if (queryParams.sort === sortingOption.value) { + return; + } + + updateQueryParams({ sort: sortingOption.value, page: FIRST_PAGE }); + }, [queryParams.sort, sortingOption, updateQueryParams]); + + useEffect(() => { + if (queryParams.keyword === debouncedKeyword) { + return; + } + + updateQueryParams({ keyword: debouncedKeyword, page: FIRST_PAGE }); + }, [queryParams.keyword, debouncedKeyword, updateQueryParams]); const handleSearchSubmit = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - setPage(1); + handlePage(FIRST_PAGE); } }; @@ -58,7 +77,7 @@ const TemplateExplorePage = () => { - πŸ”₯ μ§€κΈˆ μΈκΈ°μžˆλŠ” ν† ν”½ {selectedHotTopic && `- ${selectedHotTopic} λ³΄λŠ” 쀑`} + {selectedHotTopic ? `πŸ”₯ [ ${selectedHotTopic} ] λ³΄λŠ” 쀑` : 'πŸ”₯ μ§€κΈˆ μΈκΈ°μžˆλŠ” ν† ν”½'} @@ -91,7 +110,7 @@ const TemplateExplorePage = () => { > void; + handlePage: (page: number) => void; sortingOption: SortingOption; keyword: string; tagIds: number[]; @@ -144,7 +163,7 @@ const TemplateList = ({ const handlePageChange = (page: number) => { scroll.top(); - setPage(page); + handlePage(page); }; return ( diff --git a/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.style.ts b/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.style.ts index bc708b1d..cfaccd2c 100644 --- a/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.style.ts +++ b/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.style.ts @@ -33,6 +33,7 @@ export const CarouselItem = styled.li` width: 18rem; height: 9rem; + margin: 0.25rem 0; @media (max-width: 768px) { width: 9rem; diff --git a/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.tsx b/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.tsx index e0990f5d..626d257f 100644 --- a/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.tsx +++ b/frontend/src/pages/TemplateExplorePage/components/Carousel/Carousel.tsx @@ -6,7 +6,7 @@ import { BREAKING_POINT } from '@/style/styleConstants'; import * as S from './Carousel.style'; interface CarouselItem { - id: number; + id: string; content: React.ReactNode; } @@ -86,8 +86,8 @@ const Carousel = ({ items }: Props) => { - {displayItems.map((item) => ( - {item.content} + {displayItems.map((item, idx) => ( + {item.content} ))} diff --git a/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.style.ts b/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.style.ts index 8f79fde5..017952a4 100644 --- a/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.style.ts +++ b/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.style.ts @@ -1,16 +1,67 @@ import styled from '@emotion/styled'; +import { theme } from '@/style/theme'; + export const Topic = styled.button<{ background: string; border: string; isSelected: boolean }>` - cursor: pointer; /* μ»€μ„œ μŠ€νƒ€μΌ μΆ”κ°€ */ + cursor: pointer; + + position: relative; + + display: flex; + align-items: flex-end; width: 100%; height: 100%; + padding: 1rem; - background-color: ${({ background }) => background}; - border: ${({ isSelected, border }) => (isSelected ? `2px solid ${border}` : 'none')}; + background-image: url(${({ background }) => background}); + background-repeat: no-repeat; + background-position: center; + background-size: cover; border-radius: 12px; + box-shadow: ${({ isSelected, border }) => (isSelected ? `0 0 0 2px ${border}` : 'none')}; + + transition: box-shadow 0.2s ease-in-out; - &:hover { - border: 2px solid ${({ border }) => border}; + &::after { + pointer-events: none; + content: ''; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + background-color: rgba(0, 0, 0, 0); + border-radius: 12px; + + transition: background-color 0.3s; } + + &:hover::after { + background-color: rgba(0, 0, 0, 0.2); + } +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + justify-content: flex-start; +`; + +export const Title = styled.div` + width: fit-content; + padding: 0.25rem 0.5rem; + border: 1px solid ${theme.color.light.secondary_800}; + border-radius: 8px; +`; + +export const Description = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + align-items: flex-start; + justify-content: flex-start; `; diff --git a/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.tsx b/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.tsx index bb29a578..fce031c0 100644 --- a/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.tsx +++ b/frontend/src/pages/TemplateExplorePage/components/HotTopicCarousel/HotTopicCarousel.tsx @@ -1,47 +1,54 @@ import { Text } from '@/components'; -import { TAG_COLORS } from '@/style/tagColors'; +import { useWindowWidth } from '@/hooks'; +import { HOT_TOPIC } from '@/service/hotTopic'; +import { BREAKING_POINT } from '@/style/styleConstants'; import { theme } from '@/style/theme'; import { Carousel } from '../'; import * as S from './HotTopicCarousel.style'; -const HOT_TOPIC = [ - { key: 1, content: 'μš°ν…Œμ½”', tagIds: [359], color: TAG_COLORS[3] }, - { key: 2, content: 'ν”„λ¦¬μ½”μŠ€', tagIds: [364], color: TAG_COLORS[4] }, - { key: 4, content: 'μžλ°”μŠ€ν¬λ¦½νŠΈ', tagIds: [41, 211, 329, 351, 249, 360], color: TAG_COLORS[0] }, - { key: 3, content: 'μžλ°”', tagIds: [73, 358, 197], color: TAG_COLORS[1] }, - { key: 5, content: 'μ½”ν‹€λ¦°', tagIds: [361, 363], color: TAG_COLORS[2] }, - { key: 8, content: 'μŠ€ν”„λ§', tagIds: [14, 198], color: TAG_COLORS[7] }, - { key: 7, content: 'λ¦¬μ•‘νŠΈ', tagIds: [50, 289, 318], color: TAG_COLORS[6] }, - { key: 8, content: 'μ•ˆλ“œλ‘œμ΄λ“œ', tagIds: [362], color: TAG_COLORS[8] }, - { key: 9, content: 'μ•Œκ³ λ¦¬μ¦˜', tagIds: [261, 316, 253, 144, 143], color: TAG_COLORS[5] }, -]; - interface Props { - selectTopic: ({ tagIds, content }: { tagIds: number[]; content: string }) => void; + selectTopic: ({ tagIds, topic }: { tagIds: number[]; topic: string }) => void; selectedHotTopic: string; } -const HotTopicCarousel = ({ selectTopic, selectedHotTopic }: Props) => ( - ({ - id: key, - content: ( - { - selectTopic({ tagIds, content }); - }} - > - - {content} - - - ), - }))} - /> -); +const HotTopicCarousel = ({ selectTopic, selectedHotTopic }: Props) => { + const windowWidth = useWindowWidth(); + const isMobile = windowWidth <= BREAKING_POINT.MOBILE; + + return ( + ({ + id: topic, + content: ( + { + selectTopic({ tagIds, topic }); + }} + > + + + + {topic} + + + {!isMobile && ( + + + {description} + + {subDescription} + + )} + + + ), + }))} + /> + ); +}; export default HotTopicCarousel; diff --git a/frontend/src/pages/TemplateExplorePage/hooks/useHotTopic.ts b/frontend/src/pages/TemplateExplorePage/hooks/useHotTopic.ts index 05405c67..9a6e7c6c 100644 --- a/frontend/src/pages/TemplateExplorePage/hooks/useHotTopic.ts +++ b/frontend/src/pages/TemplateExplorePage/hooks/useHotTopic.ts @@ -1,23 +1,23 @@ -import { useState } from 'react'; +import { useQueryParams } from '@/hooks'; +import { getHotTopicContent } from '@/service/hotTopic'; export const useHotTopic = () => { - const [selectedTagIds, setSelectedTagIds] = useState([]); - const [selectedHotTopic, setSelectedHotTopic] = useState(''); + const { queryParams, updateQueryParams } = useQueryParams(); + const selectedTagIds = queryParams.tags; + const selectedHotTopic = getHotTopicContent(selectedTagIds); - const selectTopic = ({ tagIds, content }: { tagIds: number[]; content: string }) => { - if (content === selectedHotTopic) { + const selectTopic = ({ tagIds, topic }: { tagIds: number[]; topic: string }) => { + if (topic === selectedHotTopic) { resetSelectedTopic(); return; } - setSelectedTagIds([...tagIds]); - setSelectedHotTopic(content); + updateQueryParams({ tags: [...tagIds] }); }; const resetSelectedTopic = () => { - setSelectedTagIds([]); - setSelectedHotTopic(''); + updateQueryParams({ tags: [] }); }; return { selectedTagIds, selectedHotTopic, selectTopic }; diff --git a/frontend/src/pages/TemplatePage/TemplatePage.style.ts b/frontend/src/pages/TemplatePage/TemplatePage.style.ts index 043507ec..b5719130 100644 --- a/frontend/src/pages/TemplatePage/TemplatePage.style.ts +++ b/frontend/src/pages/TemplatePage/TemplatePage.style.ts @@ -1,26 +1,7 @@ -import { keyframes } from '@emotion/react'; import styled from '@emotion/styled'; import { Button } from '@/components'; -const expand = keyframes` - from { - max-height: 0; - } - to { - max-height: 1000rem; - } -`; - -const collapse = keyframes` - from { - max-height: 1000rem; - } - to { - max-height: 0; - } -`; - export const MainContainer = styled.main` display: flex; flex-direction: column; @@ -46,12 +27,6 @@ export const SidebarContainer = styled.aside` } `; -export const SyntaxHighlighterWrapper = styled.div<{ isOpen: boolean }>` - overflow: hidden; - max-height: ${({ isOpen }) => (isOpen ? '1000rem' : '0')}; - animation: ${({ isOpen }) => (!isOpen ? collapse : expand)} 0.7s ease-in-out forwards; -`; - export const NoScrollbarContainer = styled.div` scrollbar-width: none; overflow: auto; diff --git a/frontend/src/service/hotTopic.ts b/frontend/src/service/hotTopic.ts new file mode 100644 index 00000000..210d674a --- /dev/null +++ b/frontend/src/service/hotTopic.ts @@ -0,0 +1,95 @@ +import topicAlgorithm from '@/assets/images/topic_algorithm.jpg'; +import topicAndroid from '@/assets/images/topic_android.jpg'; +import topicJava from '@/assets/images/topic_java.jpg'; +import topicJs from '@/assets/images/topic_js.jpg'; +import topicKt from '@/assets/images/topic_kt.jpg'; +import topicPrecourse from '@/assets/images/topic_precourse.jpg'; +import topicReact from '@/assets/images/topic_react.jpg'; +import topicSpring from '@/assets/images/topic_spring.jpg'; +import topicWooteco from '@/assets/images/topic_wooteco.jpg'; + +export const HOT_TOPIC = [ + { + topic: 'μš°ν…Œμ½”', + description: 'ν˜„μž₯ κ²½ν—˜κ³Ό 체계적인 ꡐ윑의 λ§Œλ‚¨,', + subDescription: 'ν˜Όμžκ°€ μ•„λ‹Œ ν•¨κ»˜ μ„±μž₯ν•˜λŠ” 즐거움', + tagIds: [359], + bg: topicWooteco, + color: '#FFD269', + }, + { + topic: 'ν”„λ¦¬μ½”μŠ€', + description: 'μš°ν…Œμ½”λ₯Ό ν–₯ν•œ 첫걸음, ', + subDescription: '4μ£Όκ°„μ˜ μ„±μž₯ μ—¬μ •', + tagIds: [364], + bg: topicPrecourse, + color: '#F6836C', + }, + { + topic: 'μžλ°”μŠ€ν¬λ¦½νŠΈ', + description: 'μ›Ήμ˜ 심μž₯, ', + subDescription: 'λΈŒλΌμš°μ €λ₯Ό λ„˜μ–΄ μ „ μ˜μ—­μ„ μ•„μš°λ₯΄λŠ” 톡합 μ–Έμ–΄', + tagIds: [41, 211, 329, 351, 249, 360], + bg: topicJs, + color: '#C2B12E', + }, + { + topic: 'μžλ°”', + description: 'μ—”ν„°ν”„λΌμ΄μ¦ˆμ˜ κ°•μž, ', + subDescription: '20λ…„ 이상 μ‹ λ’°λ°›μ•„μ˜¨ μ•ˆμ •μ„±μ˜ 상징', + tagIds: [73, 358, 197], + bg: topicJava, + color: '#68B7DF', + }, + { + topic: 'μ½”ν‹€λ¦°', + description: 'λͺ¨λ˜ JVM μ–Έμ–΄μ˜ 결정체,', + subDescription: '생산성과 μ•ˆμ •μ„±μ˜ μ™„λ²½ν•œ μ‘°ν™”', + tagIds: [237, 361, 363], + bg: topicKt, + color: '#F08852', + }, + { + topic: 'μŠ€ν”„λ§', + description: 'μžλ°” ν”„λ ˆμž„μ›Œν¬μ˜ μ ˆλŒ€ κ°•μž,', + subDescription: 'μš°μ•„ν•œ μ„œλ²„ μ•„ν‚€ν…μ²˜', + tagIds: [14, 198], + bg: topicSpring, + color: '#90C470', + }, + { + topic: 'λ¦¬μ•‘νŠΈ', + description: 'μž¬μ‚¬μš©μ„±κ³Ό μƒνƒœκ³„μ˜ 힘,', + subDescription: 'ν˜„λŒ€ μ›Ή 개발의 쀑심', + tagIds: [50, 289, 318], + bg: topicReact, + color: '#4DC6D9', + }, + { + topic: 'μ•ˆλ“œλ‘œμ΄λ“œ', + description: 'λͺ¨λ°”일 혁λͺ…μ˜ μ£Όμ—­,', + subDescription: '전세계 70μ–΅ 기기의 선택', + tagIds: [236, 287, 362], + bg: topicAndroid, + color: '#6BB449', + }, + { + topic: 'μ•Œκ³ λ¦¬μ¦˜', + description: '개발자의 ν•„μˆ˜ ꡐ양,', + subDescription: '효율적 μ‚¬κ³ μ˜ 기초', + tagIds: [261, 316, 253, 144, 143], + bg: topicAlgorithm, + color: '#D0BB48', + }, +]; + +export const getHotTopicContent = (tagIds: number[]) => { + if (!tagIds.length) { + return ''; + } + + const tagId = tagIds[0]; + const selected = HOT_TOPIC.find((el) => el.tagIds.includes(tagId)); + + return selected?.topic || ''; +}; diff --git a/frontend/src/types/images.d.ts b/frontend/src/types/images.d.ts index a0168e6c..e1254978 100644 --- a/frontend/src/types/images.d.ts +++ b/frontend/src/types/images.d.ts @@ -3,6 +3,11 @@ declare module '*.png' { export default value; } +declare module '*.jpg' { + const value: string; + export default value; +} + declare module '*.svg' { import React = require('react'); diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 6f442963..2b900766 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -29,9 +29,9 @@ module.exports = { maxSize: 4 * 1024, // 4kb }, }, - generator: { - filename: `images/[name].[contenthash][ext]`, - }, + // generator: { + // filename: `images/[name].[contenthash][ext]`, + // }, }, { test: /\.svg$/i,