diff --git a/components/common/skeleton/pickSkeleton.tsx b/components/common/skeleton/pickSkeleton.tsx index 311ac343..baffe094 100644 --- a/components/common/skeleton/pickSkeleton.tsx +++ b/components/common/skeleton/pickSkeleton.tsx @@ -1,6 +1,9 @@ -import { MobilePickInfo, PickInfo } from '@pages/pickpickpick/components/PickInfo'; +import { MobilePickInfoV1, PickInfoV1 } from '@pages/pickpickpick/components/PickInfo'; -export const PickSkeleton = () => { +import AngleRightIcon from '@components/svgs/AngleRightIcon'; + +// ------------------------------픽픽픽 메인 스켈레톤 v1------------------------------ +export const PickSkeletonV1 = () => { return (
@@ -13,26 +16,26 @@ export const PickSkeleton = () => { ); }; -interface PickSkeletonListProps { +interface PickSkeletonListV1Props { rows: number; itemsInRows: number; hasInfo?: boolean; } -export const PickSkeletonList = ({ rows, itemsInRows, hasInfo }: PickSkeletonListProps) => { +export const PickSkeletonListV1 = ({ rows, itemsInRows, hasInfo }: PickSkeletonListV1Props) => { return (
{hasInfo ? ( <> - + {Array.from({ length: rows * itemsInRows - 1 }, (_, index) => ( - + ))} ) : ( <> {Array.from({ length: rows * itemsInRows }, (_, index) => ( - + ))} )} @@ -40,29 +43,103 @@ export const PickSkeletonList = ({ rows, itemsInRows, hasInfo }: PickSkeletonLis ); }; -export const MobilePickSkeletonList = ({ rows, hasInfo }: { rows: number; hasInfo?: boolean }) => { +export const MobilePickSkeletonListV1 = ({ + rows, + hasInfo, +}: { + rows: number; + hasInfo?: boolean; +}) => { const arr = Array.from({ length: rows }); return (
- {hasInfo && } + {hasInfo && } + + {arr.map((_, index) => ( + + ))} +
+ ); +}; + +export const MyPickSkeletonListV1 = ({ rows, itemsInRows }: PickSkeletonListV1Props) => { + return ( +
+ {Array.from({ length: rows * itemsInRows }, (_, index) => ( + + ))} +
+ ); +}; +// ------------------------------------------------------------------------------- + +// ------------------------------픽픽픽 메인 스켈레톤 v2------------------------------ + +export const PickSkeletonV2 = () => { + return ( +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +interface PickSkeletonListV2Props { + rows: number; + itemsInRows: number; +} + +export const PickSkeletonListV2 = ({ rows, itemsInRows }: PickSkeletonListV2Props) => { + return ( +
+ {Array.from({ length: rows * itemsInRows }, (_, index) => ( + + ))} +
+ ); +}; + +export const MobilePickSkeletonListV2 = ({ rows }: { rows: number }) => { + const arr = Array.from({ length: rows }); + + return ( +
{arr.map((_, index) => ( - + ))}
); }; -export const MyPickSkeletonList = ({ rows, itemsInRows }: PickSkeletonListProps) => { +export const MyPickSkeletonListV2 = ({ rows, itemsInRows }: PickSkeletonListV2Props) => { return (
{Array.from({ length: rows * itemsInRows }, (_, index) => ( - + ))}
); }; +// ------------------------------------------------------------------------------- /** 메인페이지 픽픽픽 스켈레톤 */ export const MainPickSkeleton = () => { @@ -86,7 +163,7 @@ export const MainPickSkeletonList = ({ itemsInRows }: MainPickSkeletonListProps) return ( <> {Array.from({ length: itemsInRows }, (_, index) => ( - + ))} ); diff --git a/components/common/title/ArrowWithTitle.tsx b/components/common/title/ArrowWithTitle.tsx index 81db1beb..c4311937 100644 --- a/components/common/title/ArrowWithTitle.tsx +++ b/components/common/title/ArrowWithTitle.tsx @@ -17,6 +17,7 @@ export const ArrowWithTitleVariants = cva(ARROW_TITLE_CLASSES, { mainTitle: ['st2', 'text-gray200'], similarPick: ['st2', 'text-white'], defaultPick: ['p1', 'text-gray100'], + defaultPickV2: ['st2', 'text-white'], }, }, }); @@ -26,8 +27,8 @@ interface ArrowWithTitleProps extends VariantProps = ({ @@ -37,6 +38,7 @@ const ArrowWithTitle: FC = ({ routeURL, className, ArrowClassName, + iconSize = { width: 7, height: 14 }, }) => { return (
@@ -51,8 +53,8 @@ const ArrowWithTitle: FC = ({ {'오른쪽
diff --git a/components/features/main/dynamicPickComponent.tsx b/components/features/main/dynamicPickComponent.tsx index 953b2b67..e2dc87ff 100644 --- a/components/features/main/dynamicPickComponent.tsx +++ b/components/features/main/dynamicPickComponent.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Link from 'next/link'; import { useInfinitePickData } from '@pages/pickpickpick/api/useInfinitePickData'; -import PickContainer from '@pages/pickpickpick/components/PickContainer'; +import PickContainerV2 from '@pages/pickpickpick/components/PickContainerV2'; import { PICK_VIEW_SIZE, MOBILE_MAIN_PICK_VIEW_SIZE, @@ -26,7 +26,7 @@ export default function DynamicPickComponent() { const getStatusComponent = () => { switch (status) { case 'pending': - return ; + return ; default: return ( @@ -38,7 +38,7 @@ export default function DynamicPickComponent() {
{group?.data.content.map((data: PickDataProps) => ( - + ))}
diff --git a/components/features/techblog/BookmarkComponent.tsx b/components/features/techblog/BookmarkComponent.tsx index 7e2d00e8..53f1ff23 100644 --- a/components/features/techblog/BookmarkComponent.tsx +++ b/components/features/techblog/BookmarkComponent.tsx @@ -28,7 +28,7 @@ export default function BookmarkComponent() {
) : ( - + )}
); diff --git a/hooks/useVerticalStepLoop.ts b/hooks/useVerticalStepLoop.ts new file mode 100644 index 00000000..919709a9 --- /dev/null +++ b/hooks/useVerticalStepLoop.ts @@ -0,0 +1,55 @@ +import { useAnimationControls } from 'framer-motion'; + +import { useEffect, useRef, useState } from 'react'; + +type Options = { + itemCount: number; + dwellMs: number; + slideMs: number; + reduceMotion: boolean | null; +}; + +export function useVerticalStepLoop({ itemCount, dwellMs, slideMs, reduceMotion }: Options) { + const controls = useAnimationControls(); + const firstItemRef = useRef(null); + const [rowHeight, setRowHeight] = useState(0); + + useEffect(() => { + if (firstItemRef.current) setRowHeight(firstItemRef.current.offsetHeight); + }, []); + + useEffect(() => { + if (reduceMotion === true || rowHeight === 0) return; + + let i = 0; + let mounted = true; + // 한 칸씩 위로 이동 후, 마지막(복제된 첫 항목)까지 도달하면 즉시 y=0으로 스냅하여 + // 시각적 깜빡임 없이 처음 상태로 되돌립니다. (복제된 첫 항목과 실제 첫 항목은 동일 내용) + const tick = async () => { + try { + i += 1; + await controls.start({ + y: -(i * rowHeight), + transition: { duration: slideMs / 1000, ease: 'easeInOut' }, + }); + if (!mounted) return; + if (i === itemCount) { + // set()은 마운트 이후에만 안전합니다. 언마운트/미바인딩 크래시 방지를 위해 + // start() + duration: 0으로 즉시 스냅 처리합니다. + await controls.start({ y: 0, transition: { duration: 0 } }); + i = 0; + } + } catch (e) { + // 언마운트 타이밍 등으로 발생 가능한 경합 에러 무시 + } + }; + + const id = setInterval(tick, dwellMs + slideMs); + return () => { + mounted = false; + clearInterval(id); + }; + }, [controls, reduceMotion, rowHeight, itemCount, dwellMs, slideMs]); + + return { controls, firstItemRef } as const; +} diff --git a/pages/myinfo/mywriting/mypick/components/MyPickStatusComponent.tsx b/pages/myinfo/mywriting/mypick/components/MyPickStatusComponent.tsx index aec1ea97..bd43023a 100644 --- a/pages/myinfo/mywriting/mypick/components/MyPickStatusComponent.tsx +++ b/pages/myinfo/mywriting/mypick/components/MyPickStatusComponent.tsx @@ -9,8 +9,8 @@ import { PickDataProps } from '@pages/pickpickpick/types/pick'; import { useObserver } from '@hooks/useObserver'; import { - MobilePickSkeletonList, - MyPickSkeletonList, + MobilePickSkeletonListV1, + MyPickSkeletonListV1, } from '@components/common/skeleton/pickSkeleton'; import { ROUTES } from '@/constants/routes'; @@ -38,9 +38,9 @@ export default function MyPickStatusComponent() { return ( <> {isMobile ? ( - + ) : ( - + )} ); @@ -77,9 +77,9 @@ export default function MyPickStatusComponent() { {isFetchingNextPage && hasNextPage && (
{isMobile ? ( - + ) : ( - + )}
)} diff --git a/pages/pickpickpick/[id]/components/pickSearchInput.tsx b/pages/pickpickpick/[id]/components/pickSearchInput.tsx new file mode 100644 index 00000000..986dcbf9 --- /dev/null +++ b/pages/pickpickpick/[id]/components/pickSearchInput.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; + +import Image from 'next/image'; + +import Search from '@public/image/techblog/search.svg'; +import XCircle from '@public/image/techblog/xCircle.svg'; + +import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; + +interface PickSearchInputProps { + keyword?: string; + onKeywordChange?: (value: string) => void; + onClear?: () => void; + onSearch?: (value: string) => void; + disabled?: boolean; +} + +export const PickSearchInput = ({ + keyword = '', + onKeywordChange, + onClear, + onSearch, + disabled = false, +}: PickSearchInputProps) => { + const { isMobile } = useMediaQueryContext(); + const [isFocused, setIsFocused] = useState(false); + + const handleClickDeleteBtn = () => { + onKeywordChange?.(''); + setIsFocused(false); + onClear?.(); + }; + + return ( +
+
+ + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + value={keyword} + onChange={(e) => onKeywordChange?.(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setIsFocused(false); + onSearch?.(keyword); + } + }} + disabled={disabled} + /> + {keyword !== '' && ( + + )} +
+
+ ); +}; diff --git a/pages/pickpickpick/[id]/constants/pickInfoConstants.ts b/pages/pickpickpick/[id]/constants/pickInfoConstants.ts new file mode 100644 index 00000000..9b58a7d6 --- /dev/null +++ b/pages/pickpickpick/[id]/constants/pickInfoConstants.ts @@ -0,0 +1,9 @@ +export const PICK_INFO_ITEMS = [ + { icon: '🔍', text: '내가 고민한 주제, 누군가 고민하지 않았을까?' }, + { icon: '😝', text: '데이터 관련해서는 여기가 더 잘하지!' }, + { icon: '🥺', text: '스타트업 로그인 방식은 어떤게 좋을까?' }, + { icon: '😓', text: '크롤링 기능은 어떤 개발툴이 적절할까?' }, +] as const; + +export const PICK_INFO_DWELL_MS = 2000; // 한 문장 대기 시간(ms) +export const PICK_INFO_SLIDE_MS = 400; // 슬라이드 애니메이션 시간(ms) diff --git a/pages/pickpickpick/api/useInfinitePickData.ts b/pages/pickpickpick/api/useInfinitePickData.ts index 14ff99b6..01002b78 100644 --- a/pages/pickpickpick/api/useInfinitePickData.ts +++ b/pages/pickpickpick/api/useInfinitePickData.ts @@ -12,14 +12,19 @@ import { PICK_VIEW_SIZE } from '../constants/pickConstants'; import { GetPickDataProps, PickDataProps } from '../types/pick'; export const getPickData = async ({ pageParam, pickSort, size }: GetPickDataProps) => { + // 아래 endpoint v2에선 기존 v1 포함 isNew,content ,thumbnailImageUrl 3개 필드 추가됨 const res = await axios.get( - `/devdevdev/api/v1/picks?size=${size ? size : PICK_VIEW_SIZE}&pickId=${pageParam}&pickSort=${pickSort}`, + `/devdevdev/api/v2/picks?size=${size ? size : PICK_VIEW_SIZE}&pickId=${pageParam}&pickSort=${pickSort}`, ); return res?.data; }; -export const useInfinitePickData = (sortOption: PickDropdownProps, size?: number) => { +export const useInfinitePickData = ( + sortOption: PickDropdownProps, + size?: number, + enabled: boolean = true, +) => { const isValidSortOption = pickpickpickDropdownOptions.includes(sortOption); const { data: pickData, @@ -39,6 +44,7 @@ export const useInfinitePickData = (sortOption: PickDropdownProps, size?: number return getPickData({ pageParam, pickSort: sortOption, size: size }); }, initialPageParam: Number.MAX_SAFE_INTEGER, + enabled, getNextPageParam: (lastPage: PageResponse) => { if (lastPage?.data?.last) { return undefined; diff --git a/pages/pickpickpick/api/usePickSearch.ts b/pages/pickpickpick/api/usePickSearch.ts new file mode 100644 index 00000000..3fafbcc9 --- /dev/null +++ b/pages/pickpickpick/api/usePickSearch.ts @@ -0,0 +1,96 @@ +import axios from 'axios'; + +import { useCallback } from 'react'; + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { PageResponse } from '@/types/pageResponse'; + +import { PICK_VIEW_SIZE, isPickSearchEnabled } from '../constants/pickConstants'; +import { PickDataProps } from '../types/pick'; + +export interface SearchPageParam { + pickId: number | string; + searchScore: number; +} + +export interface SearchPickDataProps extends PickDataProps { + searchScore: number; +} + +export const getPickSearchData = async ({ + pageParam, + keyword, + size, +}: { + pageParam: SearchPageParam | null | undefined; + keyword: string; + size?: number; +}) => { + const params = new URLSearchParams(); + params.set('size', String(size ?? PICK_VIEW_SIZE)); + params.set('keyword', keyword); + + if (pageParam && pageParam.pickId !== undefined && pageParam.searchScore !== undefined) { + params.set('pickId', String(pageParam.pickId)); + params.set('searchScore', String(pageParam.searchScore)); + } + + const res = await axios.get(`/devdevdev/api/v2/picks/search?${params.toString()}`); + + return res?.data as PageResponse; +}; + +export const usePickSearch = (keyword: string, size?: number) => { + const trimmed = keyword.trim(); + const enabled = isPickSearchEnabled(keyword); + + const { + data: searchData, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + status, + error, + isFetching, + } = useInfiniteQuery({ + queryKey: ['pickSearch', trimmed, size], + queryFn: ({ pageParam }) => getPickSearchData({ pageParam, keyword: trimmed, size: size }), + enabled, + initialPageParam: undefined, + getNextPageParam: (lastPage: PageResponse) => { + if (lastPage?.data?.last) { + return undefined; + } + + const content = lastPage?.data?.content ?? []; + if (!content.length) return undefined; + + const lastItem = content[content.length - 1]; + return { + pickId: lastItem?.id, + searchScore: (lastItem as SearchPickDataProps)?.searchScore ?? 0, + } as SearchPageParam; + }, + }); + + const onIntersect = useCallback( + ([entry]: IntersectionObserverEntry[]) => { + if (!isFetching && entry.isIntersecting && hasNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage, isFetching], + ); + + return { + searchData, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + status, + error, + isFetching, + onIntersect, + }; +}; diff --git a/pages/pickpickpick/api/useUnifiedPickFeed.ts b/pages/pickpickpick/api/useUnifiedPickFeed.ts new file mode 100644 index 00000000..6766930e --- /dev/null +++ b/pages/pickpickpick/api/useUnifiedPickFeed.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; + +import { useInfinitePickData } from './useInfinitePickData'; +import { usePickSearch } from './usePickSearch'; + +import { PickDropdownProps } from '@/stores/dropdownStore'; + +import { isPickSearchEnabled } from '../constants/pickConstants'; + +export const useUnifiedPickFeed = ( + sortOption: PickDropdownProps, + submittedKeyword: string, + size?: number, +) => { + const isSearchMode = isPickSearchEnabled(submittedKeyword); + + // 기본 피드: 검색 모드일 땐 disabled + const base = useInfinitePickData(sortOption, size, !isSearchMode); + const search = usePickSearch(submittedKeyword, size); + + const totalCount = useMemo(() => { + return ( + (isSearchMode + ? search.searchData?.pages?.[0]?.data.totalElements + : base.pickData?.pages?.[0]?.data.totalElements) ?? 0 + ); + }, [isSearchMode, search.searchData, base.pickData]); + + const pages = useMemo(() => { + return (isSearchMode ? search.searchData?.pages : base.pickData?.pages) ?? []; + }, [isSearchMode, search.searchData, base.pickData]); + + const status = isSearchMode ? search.status : base.status; + const isFetchingNextPage = isSearchMode + ? search.isFetchingNextPage + : base.isFetchingNextPage; + const hasNextPage = isSearchMode ? search.hasNextPage : base.hasNextPage; + const onIntersect = isSearchMode ? search.onIntersect : base.onIntersect; + + return { + isSearchMode, + pages, + totalCount, + status, + isFetchingNextPage, + hasNextPage, + onIntersect, + } as const; +}; diff --git a/pages/pickpickpick/components/PickActionSection.tsx b/pages/pickpickpick/components/PickActionSection.tsx new file mode 100644 index 00000000..4cfa9db1 --- /dev/null +++ b/pages/pickpickpick/components/PickActionSection.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { Dropdown } from '@components/common/dropdowns/dropdown'; +import MobileDropdown from '@components/common/dropdowns/mobileDropdown'; + +import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; + +import { PickCount } from './PickCount'; +import { WebWriteButton } from './PickWriteButton'; + +interface PickActionSectionProps { + count?: number; + hideDropdown?: boolean; + disabled?: boolean; +} + +export const PickActionSection = ({ + count = 1, + hideDropdown = false, + disabled = false, +}: PickActionSectionProps) => { + const { isMobile } = useMediaQueryContext(); + + if (isMobile) { + return ( +
+ + {!hideDropdown && } +
+ ); + } + + return ( +
+ +
+ {!hideDropdown && } + +
+
+ ); +}; diff --git a/pages/pickpickpick/components/PickAnswerV2.tsx b/pages/pickpickpick/components/PickAnswerV2.tsx new file mode 100644 index 00000000..11e47761 --- /dev/null +++ b/pages/pickpickpick/components/PickAnswerV2.tsx @@ -0,0 +1,115 @@ +import { cn } from '@utils/mergeStyle'; + +interface PickAnswerV2Props { + title: string; + isPicked: boolean; + percent: number; + isVoted: boolean; + className?: string; + imageUrl?: string; + content?: string; +} + +const DEFAULT_PERCENT = 50; +const PLACEHOLDER_TEXT = '??'; + +const PICKED_COLORS = { + progress: 'bg-primary300', + percent: 'text-primary300', + title: 'text-primary200', +} as const; + +const UNPICKED_COLORS = { + progress: 'bg-gray400', + percent: 'text-gray400', + title: 'text-gray200', +} as const; + +const DEFAULT_COLORS = { + progress: 'bg-gray300', + percent: 'text-gray200', + title: 'text-gray50', +} as const; + +export default function PickAnswerV2({ + title, + isPicked, + percent, + isVoted, + className, + imageUrl, + content, +}: PickAnswerV2Props) { + const isPickedOption = isVoted && isPicked; + const isUnpickedOption = isVoted && !isPicked; + + const colors = isPickedOption + ? PICKED_COLORS + : isUnpickedOption + ? UNPICKED_COLORS + : DEFAULT_COLORS; + + const progressBarWidth = isVoted ? `${percent}%` : `${DEFAULT_PERCENT}%`; + const percentText = isVoted ? `${percent}%` : PLACEHOLDER_TEXT; + + const hasMedia = Boolean(imageUrl || content); + + const renderMedia = () => { + const mediaClassName = cn('w-full rounded-Radius16 h-[12rem]', { + 'opacity-50': isUnpickedOption, + }); + + if (imageUrl) { + return ( + 투표 이미지 + ); + } + + if (content) { + return ( +
+

{content}

+
+ ); + } + + return null; + }; + + return ( +
  • +
    +

    {percentText}

    +
    +
    +
    +
    + + {/* 선택지 제목 */} +

    + {!hasMedia ? {title} : title} +

    + + {/* 선택지 이미지 또는 콘텐츠 */} + {renderMedia()} +
  • + ); +} diff --git a/pages/pickpickpick/components/PickContainerV2.tsx b/pages/pickpickpick/components/PickContainerV2.tsx new file mode 100644 index 00000000..d0024b58 --- /dev/null +++ b/pages/pickpickpick/components/PickContainerV2.tsx @@ -0,0 +1,153 @@ +import Image from 'next/image'; + +import { cn } from '@utils/mergeStyle'; + +import Tag from '@components/common/tag/tag'; +import ArrowWithTitle from '@components/common/title/ArrowWithTitle'; +import StatisticsItem from '@components/features/pickpickpick/StatisticsItem'; + +import exclamationGray from '@public/image/exclamation-circle-gray.svg'; +import exclamationRed from '@public/image/exclamation-circle-red.svg'; +import PurpleComment from '@public/image/pickpickpick/comment-dots-purple.svg'; +import PurpleFire from '@public/image/pickpickpick/fire-purple.svg'; + +import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; + +import { PickDataProps } from '../types/pick'; +import PickAnswerV2 from './PickAnswerV2'; + +type PickStatus = 'APPROVAL' | 'REJECT' | 'READY'; +type PickType = 'main' | 'pick'; + +interface PickContainerV2Props { + pickData: PickDataProps; + status?: PickStatus; + type?: PickType; +} + +const STATUS_MESSAGES = { + READY: '픽픽픽 등록 대기중', + REJECT: '등록이 거부된 게시물이에요', +} as const; + +const isDisabledStatus = (status?: PickStatus): boolean => { + return status === 'READY' || status === 'REJECT'; +}; + +const StatusMessage = ({ + status, + icon, + text, + color, +}: { + status: PickStatus; + icon: string; + text: string; + color: string; +}) => ( +

    + {`${status} + {text} +

    +); + +const Statistics = ({ + voteTotalCount, + commentTotalCount, +}: { + voteTotalCount: number; + commentTotalCount: number; +}) => ( +
    + + +
    +); + +export default function PickContainerV2({ pickData, status }: PickContainerV2Props) { + const disabled = isDisabledStatus(status); + const { isMobile } = useMediaQueryContext(); + + const renderStatusContent = () => { + switch (status) { + case 'READY': + return ( + + ); + case 'REJECT': + return ( + + ); + default: + return ( + + ); + } + }; + + const containerClass = cn( + `flex flex-col h-[41.8rem] ${isMobile ? 'px-[1.6rem]' : 'px-[3.2rem]'} mb-[2.4rem] py-[2.4rem] rounded-Radius16 bg-gray600`, + ); + + return ( +
    + + +
    + {pickData.isNew && } + {renderStatusContent()} +
    + +
    +
      + {pickData.pickOptions.map((option) => ( + + ))} +
    +
    +
    + ); +} diff --git a/pages/pickpickpick/components/PickCount.tsx b/pages/pickpickpick/components/PickCount.tsx new file mode 100644 index 00000000..c45efa57 --- /dev/null +++ b/pages/pickpickpick/components/PickCount.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +interface PickCountProps { + count?: number; + className?: string; +} + +export const PickCount = ({ count = 1, className = '' }: PickCountProps) => { + return ( +

    + 총 {count}건 +

    + ); +}; diff --git a/pages/pickpickpick/components/PickHeader.tsx b/pages/pickpickpick/components/PickHeader.tsx new file mode 100644 index 00000000..9005770d --- /dev/null +++ b/pages/pickpickpick/components/PickHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; + +export const PickHeader = ({ onClick }: { onClick: () => void }) => { + const { isMobile } = useMediaQueryContext(); + return ( +

    + 픽픽픽 💘 +

    + ); +}; diff --git a/pages/pickpickpick/components/PickInfo.tsx b/pages/pickpickpick/components/PickInfo.tsx index a6dbedb8..5f82cf01 100644 --- a/pages/pickpickpick/components/PickInfo.tsx +++ b/pages/pickpickpick/components/PickInfo.tsx @@ -1,4 +1,4 @@ -export const PickInfo = () => { +export const PickInfoV1 = () => { return (

    @@ -15,7 +15,7 @@ export const PickInfo = () => { ); }; -export const MobilePickInfo = () => { +export const MobilePickInfoV1 = () => { return (

    diff --git a/pages/pickpickpick/components/PickInfoV2.tsx b/pages/pickpickpick/components/PickInfoV2.tsx new file mode 100644 index 00000000..fb3ffc80 --- /dev/null +++ b/pages/pickpickpick/components/PickInfoV2.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { motion, useReducedMotion } from 'framer-motion'; + +import { useMemo } from 'react'; + +import { + PICK_INFO_ITEMS, + PICK_INFO_DWELL_MS, + PICK_INFO_SLIDE_MS, +} from '@pages/pickpickpick/[id]/constants/pickInfoConstants'; + +import { useVerticalStepLoop } from '@hooks/useVerticalStepLoop'; + +export const PickInfoV2 = () => { + // 루프 애니메이션을 자연스럽게 만들기 위해 첫 항목을 배열 끝에 한 번 더 붙입니다. + // [A, B, C] -> [A, B, C, A'] 로 만들어 C→A'도 '한 칸 위로' 슬라이드가 되게 합니다. + // 슬라이드 직후 y=0 으로 스냅(리셋)하므로 A'와 A가 동일해 화면상 깜빡임/중복 대기는 생기지 않습니다. + const loopItems = useMemo(() => [...PICK_INFO_ITEMS, PICK_INFO_ITEMS[0]], []); + const reduceMotion = useReducedMotion(); + + const dwell = PICK_INFO_DWELL_MS; + const slide = PICK_INFO_SLIDE_MS; + + const { controls, firstItemRef } = useVerticalStepLoop({ + itemCount: PICK_INFO_ITEMS.length, + dwellMs: dwell, + slideMs: slide, + reduceMotion, + }); + + return ( +

    +

    + 개발고민 혼자 끙끙 앓지말고, 픽픽픽 💘에서 함께 나눠요! +

    +
    + {reduceMotion ? ( +

    + {PICK_INFO_ITEMS[0].icon} + {PICK_INFO_ITEMS[0].text} +

    + ) : ( + + {loopItems.map((item, i) => ( +

    + {item.icon} + {item.text} +

    + ))} +
    + )} +
    +
    + ); +}; + +export const MobilePickInfoV2 = () => { + // 데스크톱과 동일한 이유로 첫 항목을 끝에 복제하여 부드러운 루프를 구성합니다. + const loopItems = useMemo(() => [...PICK_INFO_ITEMS, PICK_INFO_ITEMS[0]], []); + const reduceMotion = useReducedMotion(); + + const dwell = PICK_INFO_DWELL_MS; + const slide = PICK_INFO_SLIDE_MS; + + const { controls, firstItemRef } = useVerticalStepLoop({ + itemCount: PICK_INFO_ITEMS.length, + dwellMs: dwell, + slideMs: slide, + reduceMotion, + }); + + return ( +
    +

    + 개발고민 혼자 끙끙 앓지말고, +
    + 픽픽픽 💘에서 함께 나눠요! +

    +
    + {reduceMotion ? ( +

    + {PICK_INFO_ITEMS[0].icon} + {PICK_INFO_ITEMS[0].text} +

    + ) : ( + + {loopItems.map((item, i) => ( +

    + {item.icon} + {item.text} +

    + ))} +
    + )} +
    +
    + ); +}; diff --git a/pages/pickpickpick/components/PickWriteButton.tsx b/pages/pickpickpick/components/PickWriteButton.tsx new file mode 100644 index 00000000..9d451c3b --- /dev/null +++ b/pages/pickpickpick/components/PickWriteButton.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link'; + +import { useLoginStatusStore } from '@stores/loginStore'; +import { useLoginModalStore } from '@stores/modalStore'; + +import { MainButtonV2 } from '@components/common/buttons/mainButtonsV2'; +import MobileMainButton from '@components/common/buttons/mobileMainButton'; + +import { ROUTES } from '@/constants/routes'; +import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; + +// 픽픽픽 작성하기 버튼 컴포넌트 모바일,웹 + +export const MobileWriteButton = () => { + const { isMobile } = useMediaQueryContext(); + const { loginStatus } = useLoginStatusStore(); + const { openLoginModal, setDescription } = useLoginModalStore(); + const { POSTING } = ROUTES.PICKPICKPICK; + + const handleWriteClick = () => { + openLoginModal(); + setDescription('댑댑이가 되면 픽픽픽을 작성할 수 있어요 🥳'); + }; + + if (!isMobile) return null; + + return ( + <> + {loginStatus === 'login' ? ( + + + + ) : ( + + )} + + ); +}; + +export const WebWriteButton = ({ disabled = false }: { disabled?: boolean }) => { + const { loginStatus } = useLoginStatusStore(); + const { openLoginModal, setDescription } = useLoginModalStore(); + const { POSTING } = ROUTES.PICKPICKPICK; + const { isMobile } = useMediaQueryContext(); + + const handleWriteClick = () => { + openLoginModal(); + setDescription('댑댑이가 되면 픽픽픽을 작성할 수 있어요 🥳'); + }; + + return ( + <> + {!isMobile && ( +
    + {loginStatus === 'login' && !disabled ? ( + + + + ) : ( + + )} +
    + )} + + ); +}; diff --git a/pages/pickpickpick/constants/pickConstants.ts b/pages/pickpickpick/constants/pickConstants.ts index 9159a460..0b7d2c7a 100644 --- a/pages/pickpickpick/constants/pickConstants.ts +++ b/pages/pickpickpick/constants/pickConstants.ts @@ -2,3 +2,7 @@ export const PICK_VIEW_SIZE = 9; export const MOBILE_MAIN_PICK_VIEW_SIZE = 3; export const PICK_COMMENT_VIEW_SIZE = 10; + +// 픽픽픽 검색시 최소 입력 글자 수 +export const PICK_SEARCH_MIN_LENGTH = 1; +export const isPickSearchEnabled = (term: string) => term.trim().length >= PICK_SEARCH_MIN_LENGTH; diff --git a/pages/pickpickpick/index.page.tsx b/pages/pickpickpick/index.page.tsx index 7ffc280d..eaa547f9 100644 --- a/pages/pickpickpick/index.page.tsx +++ b/pages/pickpickpick/index.page.tsx @@ -1,161 +1,132 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import dynamic from 'next/dynamic'; -import Image from 'next/image'; import Link from 'next/link'; -import { useInfinitePickData } from '@pages/pickpickpick/api/useInfinitePickData'; +import { useUnifiedPickFeed } from '@pages/pickpickpick/api/useUnifiedPickFeed'; +import SearchNotFound from '@pages/techblog/components/searchNotFound'; -import { useLoginStatusStore } from '@stores/loginStore'; import { useLoginModalStore } from '@stores/modalStore'; import { useObserver } from '@hooks/useObserver'; -import { MainButton } from '@components/common/buttons/mainButtons'; -import MobileMainButton from '@components/common/buttons/mobileMainButton'; -import { Dropdown } from '@components/common/dropdowns/dropdown'; -import MobileDropdown from '@components/common/dropdowns/mobileDropdown'; import { LoginModal } from '@components/common/modals/modal'; -import { MobilePickSkeletonList, PickSkeletonList } from '@components/common/skeleton/pickSkeleton'; - -import IconPencil from '@public/image/pencil-alt.svg'; +import { + MobilePickSkeletonListV2, + PickSkeletonListV2, +} from '@components/common/skeleton/pickSkeleton'; import { META } from '@/constants/metaData'; import { ROUTES } from '@/constants/routes'; import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; import { PickDropdownProps, usePickDropdownStore } from '@/stores/dropdownStore'; -import { MobilePickInfo, PickInfo } from './components/PickInfo'; +import { PickSearchInput } from './[id]/components/pickSearchInput'; +import { PickActionSection } from './components/PickActionSection'; +import { PickHeader } from './components/PickHeader'; +import { MobilePickInfoV2, PickInfoV2 } from './components/PickInfoV2'; +import { MobileWriteButton } from './components/PickWriteButton'; import { PickDataProps } from './types/pick'; -const DynamicComponent = dynamic(() => import('@/pages/pickpickpick/components/PickContainer')); +const DynamicComponent = dynamic(() => import('@/pages/pickpickpick/components/PickContainerV2')); export default function Index() { const bottom = useRef(null); + const [editingKeyword, setEditingKeyword] = useState(''); + const [submittedKeyword, setSubmittedKeyword] = useState(''); - const { MAIN, POSTING } = ROUTES.PICKPICKPICK; - - const { loginStatus } = useLoginStatusStore(); - const { openLoginModal, isLoginModalOpen, setDescription } = useLoginModalStore(); + const { MAIN } = ROUTES.PICKPICKPICK; + const { isLoginModalOpen } = useLoginModalStore(); const { sortOption } = usePickDropdownStore(); const { isMobile } = useMediaQueryContext(); - const { pickData, isFetchingNextPage, hasNextPage, status, onIntersect } = useInfinitePickData( - sortOption as PickDropdownProps, - ); + const { pages, status, isFetchingNextPage, hasNextPage, onIntersect, totalCount, isSearchMode } = + useUnifiedPickFeed(sortOption as PickDropdownProps, submittedKeyword); + + useObserver({ target: bottom, onIntersect }); + + const renderContent = () => ( + <> + setSubmittedKeyword(value)} + onClear={() => setSubmittedKeyword('')} + /> + + + {totalCount === 0 && ( + + )} + +
    + {pages?.map((group, index) => ( + + {group?.data.content.map((data: PickDataProps) => ( + + + + ))} + + ))} +
    - useObserver({ - target: bottom, - onIntersect, - }); + {isFetchingNextPage && hasNextPage && ( +
    + {isMobile ? ( + + ) : ( + + )} +
    + )} + + ); const getStatusComponent = () => { switch (status) { case 'pending': return ( <> + setSubmittedKeyword(value)} + onClear={() => setSubmittedKeyword('')} + disabled + /> + + {isMobile ? ( - + ) : ( - + )} ); - default: - return ( - <> -
    - {isMobile ? : } - {isMobile ? ( -
    - -
    - ) : ( - <> - )} - - {pickData?.pages.map((group, index) => ( - - {group?.data.content.map((data: PickDataProps) => ( - - - - ))} - - ))} -
    - - {isFetchingNextPage && hasNextPage && ( -
    - {isMobile ? ( - - ) : ( - - )} -
    - )} - - ); + return renderContent(); } }; return (
    -
    -

    - 픽픽픽 💘 -

    - - {!isMobile && ( -
    - - - {loginStatus === 'login' ? ( - - } - type='button' - /> - - ) : ( - } - onClick={() => { - openLoginModal(); - setDescription('댑댑이가 되면 픽픽픽을 작성할 수 있어요 🥳'); - }} - type='button' - /> - )} -
    - )} -
    + { + setEditingKeyword(''); + setSubmittedKeyword(''); + }} + /> + {isMobile ? : } {getStatusComponent()}
    - {isMobile && - (loginStatus === 'login' ? ( - - - - ) : ( - { - openLoginModal(); - setDescription('댑댑이가 되면 픽픽픽을 작성할 수 있어요 🥳'); - }} - /> - ))} - {isLoginModalOpen && loginStatus !== 'login' && } + + {isLoginModalOpen && }
    ); } @@ -166,4 +137,4 @@ export function getStaticProps() { meta: META.PICK, }, }; -} \ No newline at end of file +} diff --git a/pages/pickpickpick/types/pick.ts b/pages/pickpickpick/types/pick.ts index c2242dec..836cd8f7 100644 --- a/pages/pickpickpick/types/pick.ts +++ b/pages/pickpickpick/types/pick.ts @@ -12,12 +12,16 @@ export interface PickDataProps { id: number; title: string; isVoted?: boolean; + isNew: boolean; // v2 추가 필드 pickOptions: { id: string; title: string; picked?: boolean | null; percent?: number; isPicked?: boolean; + // ---v2 추가 필드--- + thumbnailImageUrl: string; + content: string; }[]; voteTotalCount: number; commentTotalCount: number; diff --git a/pages/techblog/components/searchNotFound.tsx b/pages/techblog/components/searchNotFound.tsx index 163c0bb1..1ea87b3d 100644 --- a/pages/techblog/components/searchNotFound.tsx +++ b/pages/techblog/components/searchNotFound.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Image from 'next/image'; -import { useCompanyInfoStore, useSearchKeywordStore } from '@stores/techBlogStore'; +import { useCompanyInfoStore } from '@stores/techBlogStore'; import { MainButton } from '@components/common/buttons/mainButtons'; @@ -13,12 +13,17 @@ import { useMediaQueryContext } from '@/contexts/MediaQueryContext'; type SearchNotFoundProps = { type: 'company' | 'keyword'; + searchKeyword: string; + setSearchKeyword: (keyword: string) => void; }; -export default function SearchNotFound({ type }: SearchNotFoundProps) { +export default function SearchNotFound({ + type, + searchKeyword, + setSearchKeyword, +}: SearchNotFoundProps) { const { isMobile } = useMediaQueryContext(); const { companyName, resetCompanyInfo } = useCompanyInfoStore(); - const { searchKeyword, setSearchKeyword } = useSearchKeywordStore(); const KeyType = type.toUpperCase() as keyof typeof NO_TECHBLOG_DATA; const handleOnClick = () => { diff --git a/pages/techblog/index.page.tsx b/pages/techblog/index.page.tsx index 05e00dcc..46ee9a5b 100644 --- a/pages/techblog/index.page.tsx +++ b/pages/techblog/index.page.tsx @@ -110,7 +110,13 @@ export default function Index() {
    )} - {totalArticleCnt === 0 && } + {totalArticleCnt === 0 && ( + + )} ); } @@ -150,7 +156,7 @@ export default function Index() {

    {totalArticleCnt}

    - {isMobile ? : } + {isMobile ? : }
    {/* 게시글 목록 */} {getStatusComponent(techBlogData, status)} @@ -203,4 +209,4 @@ export async function getStaticProps() { console.error('Error prefetching tech blog data:', error); throw new Error('데이터를 프리패치 하는중 오류가 발생했습니다.'); } -} \ No newline at end of file +} diff --git a/styles/globals.css b/styles/globals.css index 1dfb956b..118e25af 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -265,6 +265,11 @@ body { -o-text-size-adjust: none; /* 오페라 구버전 */ } +/* 스크롤바 등장/제거에 따른 가로 시프트 방지 */ +html { + scrollbar-gutter: stable; +} + body { width: 100vw; height: 100vh; diff --git a/types/pageResponse.ts b/types/pageResponse.ts index f2c2769c..8caa4508 100644 --- a/types/pageResponse.ts +++ b/types/pageResponse.ts @@ -4,6 +4,7 @@ export interface PageResponse { first: boolean; last: boolean; size: string; + totalElements?: number; // v2 추가 필드 }; resultType: string; }