diff --git a/.storybook/main.ts b/.storybook/main.ts index e93369b..2a933c1 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { StorybookConfig } from '@storybook/nextjs'; const config: StorybookConfig = { @@ -15,23 +16,30 @@ const config: StorybookConfig = { }, staticDirs: ['../public'], webpackFinal: async config => { - if (config.module?.rules) { - config.module = config.module || {}; - config.module.rules = config.module.rules || []; + config.module = config.module || {}; + config.module.rules = config.module.rules || []; + const rules = config.module.rules; - const imageRule = config.module.rules.find(rule => - rule?.['test']?.test('.svg'), - ); - if (imageRule) { - imageRule['exclude'] = /\.svg$/; - } - - config.module.rules.push({ - test: /\.svg$/, - use: ['@svgr/webpack'], - }); + // 기존 이미지/svg 처리 rule에서 svg 제외 + const imageRule = rules.find((rule: any) => rule?.test?.test?.('.svg')); + if (imageRule) { + imageRule.exclude = /\.svg$/i; } + // SVG 처리: 기본은 URL(= next/image에 넣을 수 있음) + rules.push({ + test: /\.svg$/i, + oneOf: [ + { + resourceQuery: /component/, // import Icon from './x.svg?component' + use: ['@svgr/webpack'], + }, + { + type: 'asset/resource', // import url from './x.svg' + }, + ], + }); + return config; }, }; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b0c0be0..6e313b4 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,7 +1,18 @@ import type { Preview } from '@storybook/nextjs-vite'; import '../src/app/globals.css'; +import { pretendard } from '../src/lib/fonts/pretendard'; const preview: Preview = { + decorators: [ + Story => { + // pretendard 폰트 적용 + if (typeof document !== 'undefined') { + document.documentElement.classList.add(pretendard.className); + } + + return Story(); + }, + ], parameters: { controls: { matchers: { @@ -9,13 +20,7 @@ const preview: Preview = { date: /Date$/i, }, }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, + a11y: { test: 'todo' }, }, }; diff --git a/package.json b/package.json index 6316e52..617441e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^5.2.2", "clsx": "^2.1.1", "globals": "^16.4.0", + "motion": "^12.23.26", "immer": "^10.1.3", "next": "15.5.3", "react": "19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5629aeb..38b922d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: globals: specifier: ^16.4.0 version: 16.4.0 + motion: + specifier: ^12.23.26 + version: 12.23.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0) immer: specifier: ^10.1.3 version: 10.1.3 @@ -2989,6 +2992,20 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + framer-motion@12.23.26: + resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3653,6 +3670,26 @@ packages: module-alias@2.2.3: resolution: {integrity: sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.26: + resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -8148,6 +8185,15 @@ snapshots: typescript: 5.9.2 webpack: 5.101.3(esbuild@0.25.10) + framer-motion@12.23.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -8781,6 +8827,20 @@ snapshots: module-alias@2.2.3: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + framer-motion: 12.23.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + mrmime@2.0.1: {} ms@2.1.3: {} diff --git a/public/ex.png b/public/ex.png new file mode 100644 index 0000000..b17c649 Binary files /dev/null and b/public/ex.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5c22722..9ae88ce 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { pretendard } from '@/lib/fonts/pretendard'; import './globals.css'; +import { Suspense } from 'react'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -35,7 +36,9 @@ export default function RootLayout({ pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] " > -
{children}
+
+ {children} +
diff --git a/src/app/main/layout.tsx b/src/app/main/layout.tsx new file mode 100644 index 0000000..89d5d72 --- /dev/null +++ b/src/app/main/layout.tsx @@ -0,0 +1,14 @@ +import MainHeaderLayout from '@/features/main/components/headers/MainHeaderLayout'; + +export default function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/main/page.tsx b/src/app/main/page.tsx new file mode 100644 index 0000000..a85e695 --- /dev/null +++ b/src/app/main/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import CardListHorizontal from '@/features/main/components/cards/CardListHorizontal'; +import { useRouter } from 'next/navigation'; +import MockImg1 from '@/assets/images/mocks/card1.png'; +import MockImg2 from '@/assets/images/mocks/card2.png'; +import MockImg3 from '@/assets/images/mocks/card3.png'; +import { mockCards } from '@/features/main/data/mockCards'; +import CardStack, { + CardStackItem, +} from '@/common/components/CardStack/CardStack'; +import { useCallback } from 'react'; + +const DUMMY_CARDS: CardStackItem[] = [ + { id: '1', imageUrl: MockImg1.src }, + { id: '2', imageUrl: MockImg2.src }, + { id: '3', imageUrl: MockImg3.src }, +]; + +export default function MainPage() { + const router = useRouter(); + + const handleCardClick = useCallback( + (card: CardStackItem) => { + router.push(`/tips/${card.id}`); + }, + [router], + ); + + const handleTodayTipsBtn = () => { + router.push('/today-tips'); + }; + + const handleMonthlyTipsBtn = () => { + router.push('/monthly-tips'); + }; + + return ( +
+

+ {`안녕하세요!${'\n'}오늘도 홈마스터에서 꿀팁을 얻어가세요:)`} +

+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/monthly-tips/layout.tsx b/src/app/monthly-tips/layout.tsx new file mode 100644 index 0000000..fb124b0 --- /dev/null +++ b/src/app/monthly-tips/layout.tsx @@ -0,0 +1,24 @@ +'use client'; + +import BackIcon from '@/assets/svgs/arrow_backward.svg'; +import { useRouter } from 'next/navigation'; +import TitleHeader from '@/features/main/components/headers/TitleHeaderLayout'; + +export default function MonthlyTipsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + <> + router.back()} + sticky + /> +
{children}
+ + ); +} diff --git a/src/app/monthly-tips/page.tsx b/src/app/monthly-tips/page.tsx new file mode 100644 index 0000000..061f997 --- /dev/null +++ b/src/app/monthly-tips/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { sortCards, type SortFilterType } from '@/lib/utils/sortCards'; +import { mockCards } from '@/features/main/data/mockCards'; +import FilterBar from '@/common/components/FilterBar/FilterBar'; +import CardList from '@/common/components/CardList/CardList'; + +export default function MonthlyTipsPage() { + const [filter, setFilter] = useState('ALL'); + const results = mockCards; + const sorted = useMemo(() => sortCards(results, filter), [results, filter]); + + const filteredWithBadges = useMemo(() => { + if (filter === 'ALL') { + return sorted.map(card => ({ ...card, badges: [] })); + } + const target = filter.toLowerCase(); + return sorted.map(card => ({ + ...card, + badges: card.badges?.filter(b => b.type === target), + })); + }, [sorted, filter]); + + return ( +
+ setFilter(String(v).toUpperCase() as SortFilterType)} + /> + +
+ ); +} diff --git a/src/app/notifications/layout.tsx b/src/app/notifications/layout.tsx new file mode 100644 index 0000000..5f176b6 --- /dev/null +++ b/src/app/notifications/layout.tsx @@ -0,0 +1,24 @@ +'use client'; + +import BackIcon from '@/assets/svgs/arrow_backward.svg'; +import { useRouter } from 'next/navigation'; +import TitleHeader from '@/features/main/components/headers/TitleHeaderLayout'; + +export default function NotificationLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + <> + router.back()} + sticky + /> +
{children}
+ + ); +} diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx new file mode 100644 index 0000000..822e40b --- /dev/null +++ b/src/app/notifications/page.tsx @@ -0,0 +1,20 @@ +import NotificationCard from '@/features/notifications/components/NotificationCard'; + +export default function NotificationsPage() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/search/layout.tsx b/src/app/search/layout.tsx new file mode 100644 index 0000000..f9b291a --- /dev/null +++ b/src/app/search/layout.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect, FormEvent, KeyboardEvent } from 'react'; +import BackwardIcon from '@/assets/svgs/arrow_backward.svg'; +import SearchIcon from '@/assets/svgs/search.svg'; +import Image from 'next/image'; + +export default function SearchLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + const initialQuery = searchParams.get('query') ?? ''; + + const [q, setQ] = useState(initialQuery); + + useEffect(() => { + setQ(initialQuery); + }, [initialQuery]); + + // ✅ 검색 실행 + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const next = q.trim(); + if (!next) { + router.push('/search'); + return; + } + router.push(`/search?query=${encodeURIComponent(next)}`); + }; + + // ✅ 엔터키로도 검색 가능하게 + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSubmit(e as never); + } + }; + + // ✅ 뒤로가기 동작: 검색어 있을 때는 초기화, 없으면 메인으로 + const onBack = () => { + if (q.trim()) { + setQ(''); + router.push('/search'); + } else { + router.replace('/main'); + } + }; + + return ( + <> +
+
+ {/* 뒤로가기 */} + + + {/* 검색 입력 */} +
+
+ setQ(e.target.value)} + onKeyDown={onKeyDown} + placeholder="검색어를 입력해 주세요" + className="bg-transparent outline-none text-body2 pr-3 flex-1" + /> + {/* 쿼리 파라미터가 없을 때만 검색 아이콘 표시 */} + {!initialQuery && ( + + )} +
+
+
+
+ +
{children}
+ + ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..30eb9a8 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +import HashtagList from '@/features/search/components/HashtagList'; +import { sortCards, type SortFilterType } from '@/lib/utils/sortCards'; +import { mockCards } from '@/features/main/data/mockCards'; +import FilterBar from '@/common/components/FilterBar/FilterBar'; +import CardList from '@/common/components/CardList/CardList'; + +export default function SearchPage() { + const router = useRouter(); + const sp = useSearchParams(); + const query = (sp.get('query') ?? '').trim(); + + // 대문자 필터 사용 + const [filter, setFilter] = useState('ALL'); + + // 1) 쿼리로 결과 필터링 + const results = useMemo(() => { + if (!query) return []; + const q = query.toLowerCase(); + return mockCards.filter(c => c.title.toLowerCase().includes(q)); + }, [query]); + + // 2) 정렬 적용 (내림차순 정렬은 sortCards 내부에서 수행) + const sorted = useMemo(() => sortCards(results, filter), [results, filter]); + + // 3) 현재 필터에 따라 보여줄 배지 필터링 + const filteredWithBadges = useMemo(() => { + if (filter === 'ALL') { + // 전체보기에서는 배지 숨김 + return sorted.map(card => ({ ...card, badges: [] })); + } + const target = filter.toLowerCase(); // 'LIKE' -> 'like' + return sorted.map(card => ({ + ...card, + badges: card.badges?.filter(b => b.type === target), + })); + }, [sorted, filter]); + + // mock 해시태그 + const popularTags = ['청소', '방', '정리', '인테리어', '가구', '청소도구']; + const recommendTags = [ + '주방정리', + '미니멀', + '러그', + '수납', + '벽선반', + '조명', + ]; + const goTag = (tag: string) => + router.push(`/search?query=${encodeURIComponent(tag)}`); + + // 쿼리 없으면 추천 섹션 + if (!query) { + return ( +
+ + +
+ ); + } + + const hasResults = sorted.length > 0; + + // 결과 0건이면 텍스트만 + if (!hasResults) { + return ( +
+ {`검색 결과가 존재하지 않습니다.\n다른 검색어로 검색해 보세요!`} +
+ ); + } + + return ( +
+ {/* FilterBar가 소문자를 보낸다면 대문자로 변환해서 상태에 반영 ex) all -> ALL*/} + setFilter(String(v).toUpperCase() as SortFilterType)} + /> + + +
+ ); +} diff --git a/src/app/today-tips/layout.tsx b/src/app/today-tips/layout.tsx new file mode 100644 index 0000000..e5105c6 --- /dev/null +++ b/src/app/today-tips/layout.tsx @@ -0,0 +1,24 @@ +'use client'; + +import BackIcon from '@/assets/svgs/arrow_backward.svg'; +import { useRouter } from 'next/navigation'; +import TitleHeader from '@/features/main/components/headers/TitleHeaderLayout'; + +export default function TodayTipsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + <> + router.back()} + sticky + /> +
{children}
+ + ); +} diff --git a/src/app/today-tips/page.tsx b/src/app/today-tips/page.tsx new file mode 100644 index 0000000..4a1725c --- /dev/null +++ b/src/app/today-tips/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { sortCards, type SortFilterType } from '@/lib/utils/sortCards'; +import { mockCards } from '@/features/main/data/mockCards'; +import FilterBar from '@/common/components/FilterBar/FilterBar'; +import CardList from '@/common/components/CardList/CardList'; + +export default function TodayTipsPage() { + const [filter, setFilter] = useState('ALL'); + const results = mockCards; + const sorted = useMemo(() => sortCards(results, filter), [results, filter]); + + const filteredWithBadges = useMemo(() => { + if (filter === 'ALL') { + return sorted.map(card => ({ ...card, badges: [] })); + } + const target = filter.toLowerCase(); + return sorted.map(card => ({ + ...card, + badges: card.badges?.filter(b => b.type === target), + })); + }, [sorted, filter]); + + return ( +
+ setFilter(String(v).toUpperCase() as SortFilterType)} + /> + +
+ ); +} diff --git a/src/app/trending-tips/layout.tsx b/src/app/trending-tips/layout.tsx new file mode 100644 index 0000000..7956d42 --- /dev/null +++ b/src/app/trending-tips/layout.tsx @@ -0,0 +1,24 @@ +'use client'; + +import BackIcon from '@/assets/svgs/arrow_backward.svg'; +import { useRouter } from 'next/navigation'; +import TitleHeader from '@/features/main/components/headers/TitleHeaderLayout'; + +export default function TrendingTipsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + <> + router.back()} + sticky + /> +
{children}
+ + ); +} diff --git a/src/app/trending-tips/page.tsx b/src/app/trending-tips/page.tsx new file mode 100644 index 0000000..5e3a13d --- /dev/null +++ b/src/app/trending-tips/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { sortCards, type SortFilterType } from '@/lib/utils/sortCards'; +import { mockCards } from '@/features/main/data/mockCards'; +import FilterBar from '@/common/components/FilterBar/FilterBar'; +import CardList from '@/common/components/CardList/CardList'; + +export default function TrendingTipsPage() { + const [filter, setFilter] = useState('ALL'); + const results = mockCards; + const sorted = useMemo(() => sortCards(results, filter), [results, filter]); + + const filteredWithBadges = useMemo(() => { + if (filter === 'ALL') { + return sorted.map(card => ({ ...card, badges: [] })); + } + const target = filter.toLowerCase(); + return sorted.map(card => ({ + ...card, + badges: card.badges?.filter(b => b.type === target), + })); + }, [sorted, filter]); + + return ( +
+ setFilter(String(v).toUpperCase() as SortFilterType)} + /> + +
+ ); +} diff --git a/src/assets/images/mocks/card1.png b/src/assets/images/mocks/card1.png new file mode 100644 index 0000000..c908010 Binary files /dev/null and b/src/assets/images/mocks/card1.png differ diff --git a/src/assets/images/mocks/card2.png b/src/assets/images/mocks/card2.png new file mode 100644 index 0000000..191cfb6 Binary files /dev/null and b/src/assets/images/mocks/card2.png differ diff --git a/src/assets/images/mocks/card3.png b/src/assets/images/mocks/card3.png new file mode 100644 index 0000000..cdfa8e2 Binary files /dev/null and b/src/assets/images/mocks/card3.png differ diff --git a/src/assets/images/mocks/tempImg.png b/src/assets/images/mocks/tempImg.png new file mode 100644 index 0000000..9e8c1ca Binary files /dev/null and b/src/assets/images/mocks/tempImg.png differ diff --git a/src/assets/svgs/arrow_backward.svg b/src/assets/svgs/arrow_backward.svg new file mode 100644 index 0000000..8258748 --- /dev/null +++ b/src/assets/svgs/arrow_backward.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svgs/arrow_forward.svg b/src/assets/svgs/arrow_forward.svg new file mode 100644 index 0000000..34c8335 --- /dev/null +++ b/src/assets/svgs/arrow_forward.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svgs/bookmark.svg b/src/assets/svgs/bookmark.svg new file mode 100644 index 0000000..ac2d530 --- /dev/null +++ b/src/assets/svgs/bookmark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svgs/heart.svg b/src/assets/svgs/heart.svg new file mode 100644 index 0000000..406e1c6 --- /dev/null +++ b/src/assets/svgs/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/plus.svg b/src/assets/svgs/plus.svg new file mode 100644 index 0000000..a7f2a41 --- /dev/null +++ b/src/assets/svgs/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/search.svg b/src/assets/svgs/search.svg new file mode 100644 index 0000000..04ff27c --- /dev/null +++ b/src/assets/svgs/search.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/svgs/share.svg b/src/assets/svgs/share.svg new file mode 100644 index 0000000..ac0e4b8 --- /dev/null +++ b/src/assets/svgs/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/common/components/Card/Card.stories.tsx b/src/common/components/Card/Card.stories.tsx new file mode 100644 index 0000000..6de779d --- /dev/null +++ b/src/common/components/Card/Card.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import Card from './Card'; +import type { CardProps } from './Card'; +import type { CardBadgeType } from '../CardBadge/CardBadge'; + +type PlaygroundArgs = CardProps & { + badgeType: CardBadgeType; + badgeCount: number; +}; + +const meta: Meta = { + title: 'Components/Card/Card', + component: Card, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Card는 이미지/타이틀을 보여주는 카드 컴포넌트입니다. `href`가 있으면 전체 영역이 링크로 동작하며, `badges`를 통해 이미지 위 배지를 오버레이할 수 있습니다.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + imageSrc: { + control: 'text', + description: '이미지 URL(현재 string만 지원)', + }, + imageAlt: { control: 'text' }, + title: { control: 'text' }, + href: { control: 'text', description: '있으면 링크 카드로 동작' }, + className: { control: 'text' }, + showBadge: { control: 'boolean', description: '배지 렌더링 여부' }, + badges: { control: false }, + badgeType: { + control: { type: 'select' }, + options: ['like', 'save', 'share'], + description: 'Playground: 배지 타입', + }, + badgeCount: { + control: { type: 'number' }, + description: 'Playground: 배지 카운트', + }, + }, + args: { + imageSrc: 'https://picsum.photos/seed/card/600/520', + imageAlt: 'card image', + title: '꿀팁 제목이 들어갑니다', + className: 'w-[220px]', + showBadge: true, + badgeType: 'like', + badgeCount: 12, + }, +}; + +export default meta; +type Story = StoryObj; + +function renderCard(args: PlaygroundArgs) { + const { badgeType, badgeCount, showBadge, ...cardArgs } = args; + + return ( + + ); +} + +export const Default: Story = { + render: renderCard, +}; + +export const WithRouter: Story = { + args: { + href: '/tips/1', + }, + render: renderCard, + parameters: { + docs: { + description: { story: '클릭 시 카드 전체가 링크로 동작합니다.' }, + }, + }, +}; + +export const BadgeHidden: Story = { + args: { + showBadge: false, + badgeType: 'like', + badgeCount: 99, + }, + render: renderCard, +}; + +export const LongTitle: Story = { + args: { + title: + '제목이 아주 길어질 때 줄바꿈 레이아웃이 어떻게 보이는지 확인하는 스토리입니다', + badgeType: 'like', + badgeCount: 999, + }, + render: renderCard, +}; diff --git a/src/common/components/Card/Card.tsx b/src/common/components/Card/Card.tsx new file mode 100644 index 0000000..566f64e --- /dev/null +++ b/src/common/components/Card/Card.tsx @@ -0,0 +1,69 @@ +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import CardBadge, { + CardBadgeProps, + CardBadgeType, +} from '@/common/components/CardBadge/CardBadge'; + +export interface CardProps { + imageSrc: string; + imageAlt?: string; + title: string; + href?: string; + className?: string; + badges?: Array<{ + type: CardBadgeType; + count?: number | string; + active?: boolean; + onClick?: () => void; + }>; + showBadge?: boolean; +} + +export default function Card({ + imageSrc, + imageAlt = '', + title, + href, + className = '', + badges = [], + showBadge = true, +}: CardProps) { + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => + href ? {children} : <>{children}; + + return ( +
+
+ {imageAlt} + + {/* 배지 오버레이 */} + {badges.length > 0 && showBadge && ( +
+ {badges.map((b, idx) => ( + + ))} +
+ )} + {/* 전체 클릭 가능하게 */} + + + +
+ +
+ +

{title}

+
+
+
+ ); +} diff --git a/src/common/components/CardBadge/CardBadge.stories.tsx b/src/common/components/CardBadge/CardBadge.stories.tsx new file mode 100644 index 0000000..c0ad1a2 --- /dev/null +++ b/src/common/components/CardBadge/CardBadge.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import CardBadge from './CardBadge'; +import type { CardBadgeProps, CardBadgeType } from './CardBadge'; + +type PlaygroundArgs = CardBadgeProps & { + badgeType: CardBadgeType; + badgeCount?: number; +}; + +const meta: Meta = { + title: 'Components/Card/CardBadge', + component: CardBadge, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'CardBadge는 카드 이미지 위에 표시되는 배지 컴포넌트입니다. ' + + 'type에 따라 아이콘이 바뀌며, count는 선택적으로 표시됩니다.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + type: { control: false }, + count: { control: false }, + className: { control: 'text' }, + onClick: { action: 'clicked' }, + + badgeType: { + control: { type: 'select' }, + options: ['like', 'save', 'share'], + description: '배지 타입 선택', + }, + badgeCount: { + control: { type: 'number' }, + description: '배지 숫자', + }, + }, + args: { + badgeType: 'like', + badgeCount: 12, + }, +}; + +export default meta; +type Story = StoryObj; + +function renderBadge(args: PlaygroundArgs) { + const { badgeType, badgeCount, ...rest } = args; + + return ( + + ); +} + +export const Like: Story = { + args: { badgeType: 'like', badgeCount: 12 }, + render: args => ( +
+ {renderBadge(args)} +
+ ), +}; + +export const Save: Story = { + args: { badgeType: 'save', badgeCount: 3 }, + render: args => ( +
+ {renderBadge(args)} +
+ ), +}; + +export const Share: Story = { + args: { badgeType: 'share', badgeCount: 101 }, + render: args => ( +
+ {renderBadge(args)} +
+ ), +}; diff --git a/src/common/components/CardBadge/CardBadge.tsx b/src/common/components/CardBadge/CardBadge.tsx new file mode 100644 index 0000000..a9c935b --- /dev/null +++ b/src/common/components/CardBadge/CardBadge.tsx @@ -0,0 +1,52 @@ +'use client'; + +import HeartIcon from '@/assets/svgs/heart.svg'; +import BookmarkIcon from '@/assets/svgs/bookmark.svg'; +import ShareIcon from '@/assets/svgs/share.svg'; + +import Image from 'next/image'; + +export type CardBadgeType = 'like' | 'save' | 'share'; + +export interface CardBadgeProps { + type: CardBadgeType; + count?: number | string; + className?: string; + onClick?: () => void; +} + +const iconByType = { + like: HeartIcon, + save: BookmarkIcon, + share: ShareIcon, +}; + +export default function CardBadge({ + type, + count, + className = '', + onClick, +}: CardBadgeProps) { + const icon = iconByType[type]; + + return ( + + ); +} diff --git a/src/common/components/CardList/CardList.stories.tsx b/src/common/components/CardList/CardList.stories.tsx new file mode 100644 index 0000000..b5e4c35 --- /dev/null +++ b/src/common/components/CardList/CardList.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import CardList from './CardList'; +import type { CardListProps, CardListItem } from './CardList'; +import { CardBadgeType } from '@/common/components/CardBadge/CardBadge'; + +type PlaygroundArgs = CardListProps & { + badgeType: CardBadgeType; + badgeCount?: number; +}; + +const meta: Meta = { + title: 'Components/Card/CardList', + component: CardList, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'CardList는 카드들을 2열 그리드로 렌더링하는 리스트 컴포넌트입니다. `items`로 카드 데이터를 전달하고, `showBadge`로 배지 노출 여부를 제어합니다.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + className: { control: 'text' }, + showBadge: { control: 'boolean' }, + items: { control: false }, + badgeType: { + control: { type: 'select' }, + options: ['like', 'save', 'share'], + description: '모든 카드에 적용할 배지 타입', + }, + badgeCount: { + control: { type: 'number' }, + description: '모든 카드에 적용할 배지 카운트', + }, + }, + args: { + showBadge: true, + className: 'w-[480px]', + badgeType: 'like', + badgeCount: 12, + items: [ + { + id: 1, + imageSrc: 'https://picsum.photos/seed/tip1/600/520', + imageAlt: 'tip 1', + title: '꿀팁 1', + href: '/tips/1', + badges: [{ type: 'like', count: 12 }], + }, + { + id: 2, + imageSrc: 'https://picsum.photos/seed/tip2/600/520', + imageAlt: 'tip 2', + title: '꿀팁 2', + href: '/tips/2', + badges: [{ type: 'save', count: 3 }], + }, + { + id: 3, + imageSrc: 'https://picsum.photos/seed/tip3/600/520', + imageAlt: 'tip 3', + title: '꿀팁 3', + href: '/tips/3', + badges: [{ type: 'share', count: 1 }], + }, + { + id: 4, + imageSrc: 'https://picsum.photos/seed/tip4/600/520', + imageAlt: 'tip 4', + title: '제목이 길어질 때 줄바꿈이 어떻게 되는지 확인하는 카드', + href: '/tips/4', + badges: [{ type: 'like', count: 999 }], + }, + ] satisfies CardListItem[], + } satisfies PlaygroundArgs, +}; + +export default meta; +type Story = StoryObj; + +function applyUniformBadge(args: PlaygroundArgs): CardListProps { + const { badgeType, badgeCount, showBadge, items, ...rest } = args; + + const nextItems = (items ?? []).map(item => ({ + ...item, + badges: showBadge ? [{ type: badgeType, count: badgeCount }] : [], + })); + + return { + ...(rest as Omit), + showBadge, + items: nextItems, + }; +} + +export const Default: Story = { + render: args => , +}; + +export const BadgeHidden: Story = { + args: { showBadge: false }, + render: args => , +}; + +export const ManyItems: Story = { + args: { + items: Array.from({ length: 8 }).map((_, i) => ({ + id: i + 1, + imageSrc: `https://picsum.photos/seed/many-${i}/600/520`, + imageAlt: `many ${i + 1}`, + title: `꿀팁 ${i + 1}`, + href: `/tips/${i + 1}`, + badges: [], + })), + }, + render: args => , +}; diff --git a/src/common/components/CardList/CardList.tsx b/src/common/components/CardList/CardList.tsx new file mode 100644 index 0000000..1d9a61c --- /dev/null +++ b/src/common/components/CardList/CardList.tsx @@ -0,0 +1,40 @@ +'use client'; + +import Card, { CardProps } from '../Card/Card'; + +export interface CardListItem + extends Pick< + CardProps, + 'imageSrc' | 'imageAlt' | 'title' | 'href' | 'badges' + > { + id: string | number; +} + +export interface CardListProps { + items: CardListItem[]; + className?: string; + showBadge?: boolean; +} + +export default function CardList({ + items, + showBadge, + className = '', +}: CardListProps) { + return ( +
    + {items.map(item => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/src/common/components/CardStack/CardStack.stories.tsx b/src/common/components/CardStack/CardStack.stories.tsx new file mode 100644 index 0000000..70aed25 --- /dev/null +++ b/src/common/components/CardStack/CardStack.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import CardStack from './CardStack'; +import type { CardStackItem } from './CardStack'; + +const meta: Meta = { + title: 'Components/Card/CardStack', + component: CardStack, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'CardStack은 최대 3장의 카드를 우측 상단 방향으로 겹쳐 보여주는 스택 컴포넌트입니다. ' + + '상단 카드는 드래그로 넘길 수 있고, 탭(클릭) 시 onCardClick을 호출합니다.', + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +function makeCards(n: number): CardStackItem[] { + return Array.from({ length: n }).map((_, i) => ({ + id: `card-${i + 1}`, + imageUrl: `https://picsum.photos/seed/cardstack-${i + 1}/800/800`, + alt: `card ${i + 1}`, + })); +} + +export const Default: Story = { + args: { + cards: makeCards(3), + onCardClick: card => console.log('onCardClick:', card.id), + onIndexChange: idx => console.log('onIndexChange:', idx), + }, + render: args => ( +
+ +
+ ), +}; diff --git a/src/common/components/CardStack/CardStack.tsx b/src/common/components/CardStack/CardStack.tsx new file mode 100644 index 0000000..f627220 --- /dev/null +++ b/src/common/components/CardStack/CardStack.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState } from 'react'; +import { motion } from 'motion/react'; +import Image, { StaticImageData } from 'next/image'; + +export type CardStackItem = { + id: string; + imageUrl: string | StaticImageData; + alt?: string; +}; + +type CardStackProps = { + cards: CardStackItem[]; + onCardClick?: (card: CardStackItem) => void; + onIndexChange?: (index: number) => void; +}; + +const MAX_VISIBLE = 3; + +export default function CardStack({ + cards, + onCardClick, + onIndexChange, +}: CardStackProps) { + const [index, setIndex] = useState(0); + + const next = () => { + setIndex(prev => { + const nextIndex = (prev + 1) % cards.length; + onIndexChange?.(nextIndex); + return nextIndex; + }); + }; + + return ( +
+ {Array.from({ length: Math.min(MAX_VISIBLE, cards.length) }) + .map((_, i) => { + const cardIndex = (index + i) % cards.length; + const card = cards[cardIndex]; + const isTop = i === 0; + + // 우측 상단으로 겹쳐 보이게 + const x = i * 24; // 오른쪽 + const y = i * -22; // 위쪽 + const scale = 1 - i * 0.04; + + return ( + { + if (Math.abs(info.offset.x) > 120) next(); + }} + onClick={() => { + if (isTop) onCardClick?.(card); + }} + > +
+ {card.alt + {!isTop &&
} +
+ + ); + }) + .reverse()} +
+ ); +} diff --git a/src/common/components/FilterBar/FilterBar.stories.tsx b/src/common/components/FilterBar/FilterBar.stories.tsx new file mode 100644 index 0000000..b76411f --- /dev/null +++ b/src/common/components/FilterBar/FilterBar.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import FilterBar from './FilterBar'; +import type { FilterBarProps } from './FilterBar'; + +const meta: Meta = { + title: 'Components/Filter/FilterBar', + component: FilterBar, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'FilterBar는 필터 옵션을 버튼 형태로 제공하는 UI 컴포넌트입니다.\n\n' + + '- 내부 state로 선택 값을 관리합니다.\n' + + '- `defaultValue`는 초기 선택 값으로 사용됩니다.\n' + + '- 버튼 클릭 시 `onChange(value)`가 호출됩니다.\n\n', + }, + }, + }, + argTypes: { + defaultValue: { + control: { type: 'select' }, + options: ['all', 'like', 'save', 'share'], + description: '초기 선택 값', + table: { + type: { summary: `'all' | 'like' | 'save' | 'share'` }, + defaultValue: { summary: 'all' }, + }, + }, + onChange: { + action: 'change', + description: '필터 선택 시 호출되는 콜백', + table: { + type: { summary: '(value: string) => void' }, + }, + }, + }, + args: { + defaultValue: 'all', + } satisfies FilterBarProps, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: args => , + parameters: { + docs: { + description: { + story: + '기본 상태의 FilterBar입니다. Controls에서 defaultValue를 변경하면 ' + + '컴포넌트가 리마운트되어 선택 상태가 반영됩니다.', + }, + }, + }, +}; + +export const DefaultLikeSelected: Story = { + args: { defaultValue: 'like' }, + render: args => , +}; + +export const DefaultSaveSelected: Story = { + args: { defaultValue: 'save' }, + render: args => , +}; + +export const DefaultShareSelected: Story = { + args: { defaultValue: 'share' }, + render: args => , +}; diff --git a/src/common/components/FilterBar/FilterBar.tsx b/src/common/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..618da0a --- /dev/null +++ b/src/common/components/FilterBar/FilterBar.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useState } from 'react'; + +const filterOptions = [ + { label: '전체보기', value: 'all' }, + { label: '좋아요순', value: 'like' }, + { label: '저장많은순', value: 'save' }, + { label: '공유많은순', value: 'share' }, +]; + +export interface FilterBarProps { + defaultValue?: string; + onChange?: (value: string) => void; +} + +export default function FilterBar({ + defaultValue = 'all', + onChange, +}: FilterBarProps) { + const [selected, setSelected] = useState(defaultValue); + + const handleSelect = (value: string) => { + setSelected(value); + onChange?.(value); + }; + + return ( +
+ {filterOptions.map(option => ( + + ))} +
+ ); +} diff --git a/src/features/main/components/cards/CardListHorizontal.tsx b/src/features/main/components/cards/CardListHorizontal.tsx new file mode 100644 index 0000000..fd87853 --- /dev/null +++ b/src/features/main/components/cards/CardListHorizontal.tsx @@ -0,0 +1,45 @@ +import CardList from '@/common/components/CardList/CardList'; +import Image from 'next/image'; +import ForwardIcon from '@/assets/svgs/arrow_forward.svg'; + +export interface CardListHorizontalProps { + icon?: string; + title?: string; + btnTitle?: string; + width?: number; + height?: number; + items: React.ComponentProps['items']; + onClick?: () => void; + showBadge?: boolean; +} + +export default function CardListHorizontal({ + icon = ForwardIcon, + title = '오늘의 카드', + btnTitle = '더보기', + width = 12, + height = 12, + items, + onClick, + showBadge, +}: CardListHorizontalProps) { + const handleBtn = () => { + onClick?.(); + }; + + return ( + <> +
+

{title}

+ +
+ + + ); +} diff --git a/src/features/main/components/headers/MainHeaderLayout.tsx b/src/features/main/components/headers/MainHeaderLayout.tsx new file mode 100644 index 0000000..edae490 --- /dev/null +++ b/src/features/main/components/headers/MainHeaderLayout.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Image from 'next/image'; +import SearchIcon from '@/assets/svgs/search.svg'; +import { useRouter } from 'next/navigation'; + +interface MainHeaderLayoutProps { + title?: string; +} + +export default function MainHeaderLayout({ title }: MainHeaderLayoutProps) { + const router = useRouter(); + + const handleSearchBtn = () => { + router.push('/search'); + }; + + return ( +
+ {/* 가운데 타이틀 */} + {title && {title}} + {/* 뒤로가기 버튼 */} + +
+ ); +} diff --git a/src/features/main/components/headers/TitleHeaderLayout.tsx b/src/features/main/components/headers/TitleHeaderLayout.tsx new file mode 100644 index 0000000..be1b771 --- /dev/null +++ b/src/features/main/components/headers/TitleHeaderLayout.tsx @@ -0,0 +1,49 @@ +'use client'; + +import Image from 'next/image'; +import { ReactNode } from 'react'; +import BackwardIcon from '@/assets/svgs/arrow_backward.svg'; + +export interface AppHeaderProps { + title: string | ReactNode; + leftIcon?: string; + onIconClick?: () => void; + sticky?: boolean; + className?: string; +} + +export default function TitleHeader({ + title, + leftIcon = BackwardIcon, + onIconClick, + sticky = false, + className = '', +}: AppHeaderProps) { + const ContainerTag = sticky ? 'header' : 'div'; + + return ( + + {/* Left icon */} +
+ {leftIcon && ( + + )} +
+ {/* Title */} +

{title}

+
+ ); +} diff --git a/src/features/main/data/mockCards.ts b/src/features/main/data/mockCards.ts new file mode 100644 index 0000000..b5b9579 --- /dev/null +++ b/src/features/main/data/mockCards.ts @@ -0,0 +1,158 @@ +import { CardBadgeType } from '@/common/components/CardBadge/CardBadge'; + +export const mockCards = [ + { + id: 1, + imageSrc: '/ex.png', + title: '오늘은 맛있는 반찬을 만들어볼꺼에용~!!', + href: '/tips/1', + badges: [ + { type: 'like' as CardBadgeType, count: 100 }, + { type: 'save' as CardBadgeType, count: 23 }, + { type: 'share' as CardBadgeType, count: 12 }, + ], + }, + { + id: 2, + imageSrc: '/ex.png', + title: '점심 도시락 꿀조합', + href: '/tips/2', + badges: [ + { type: 'like' as CardBadgeType, count: 54 }, + { type: 'save' as CardBadgeType, count: 32 }, + { type: 'share' as CardBadgeType, count: 10 }, + ], + }, + { + id: 3, + imageSrc: '/ex.png', + title: '피콕 홈다이닝 행사 소식', + href: '/tips/3', + badges: [ + { type: 'like' as CardBadgeType, count: 22 }, + { type: 'save' as CardBadgeType, count: 14 }, + { type: 'share' as CardBadgeType, count: 12 }, + ], + }, + { + id: 4, + imageSrc: '/ex.png', + title: '간단한 아침 샌드위치 레시피', + href: '/tips/4', + badges: [ + { type: 'like' as CardBadgeType, count: 45 }, + { type: 'save' as CardBadgeType, count: 38 }, + { type: 'share' as CardBadgeType, count: 18 }, + ], + }, + { + id: 5, + imageSrc: '/ex.png', + title: '비 오는 날엔 부침개!', + href: '/tips/5', + badges: [ + { type: 'like' as CardBadgeType, count: 71 }, + { type: 'save' as CardBadgeType, count: 87 }, + { type: 'share' as CardBadgeType, count: 22 }, + ], + }, + { + id: 6, + imageSrc: '/ex.png', + title: '남은 반찬으로 도시락 싸기 꿀팁', + href: '/tips/6', + badges: [ + { type: 'like' as CardBadgeType, count: 120 }, + { type: 'save' as CardBadgeType, count: 42 }, + { type: 'share' as CardBadgeType, count: 19 }, + ], + }, + { + id: 7, + imageSrc: '/ex.png', + title: '피크닉 도시락 아이디어 모음', + href: '/tips/7', + badges: [ + { type: 'like' as CardBadgeType, count: 88 }, + { type: 'save' as CardBadgeType, count: 54 }, + { type: 'share' as CardBadgeType, count: 20 }, + ], + }, + { + id: 8, + imageSrc: '/ex.png', + title: '간단한 디저트 만들기', + href: '/tips/8', + badges: [ + { type: 'like' as CardBadgeType, count: 42 }, + { type: 'save' as CardBadgeType, count: 56 }, + { type: 'share' as CardBadgeType, count: 15 }, + ], + }, + { + id: 9, + imageSrc: '/ex.png', + title: '홈카페 음료 레시피', + href: '/tips/9', + badges: [ + { type: 'like' as CardBadgeType, count: 77 }, + { type: 'save' as CardBadgeType, count: 29 }, + { type: 'share' as CardBadgeType, count: 17 }, + ], + }, + { + id: 10, + imageSrc: '/ex.png', + title: '편의점 재료로 만드는 야식', + href: '/tips/10', + badges: [ + { type: 'like' as CardBadgeType, count: 59 }, + { type: 'save' as CardBadgeType, count: 41 }, + { type: 'share' as CardBadgeType, count: 34 }, + ], + }, + { + id: 11, + imageSrc: '/ex.png', + title: '한그릇 요리 베스트 5', + href: '/tips/11', + badges: [ + { type: 'like' as CardBadgeType, count: 98 }, + { type: 'save' as CardBadgeType, count: 63 }, + { type: 'share' as CardBadgeType, count: 27 }, + ], + }, + { + id: 12, + imageSrc: '/ex.png', + title: '샐러드 드레싱 직접 만들기', + href: '/tips/12', + badges: [ + { type: 'like' as CardBadgeType, count: 84 }, + { type: 'save' as CardBadgeType, count: 65 }, + { type: 'share' as CardBadgeType, count: 31 }, + ], + }, + { + id: 13, + imageSrc: '/ex.png', + title: '주말에 만들어두는 밀프렙 꿀팁', + href: '/tips/13', + badges: [ + { type: 'like' as CardBadgeType, count: 150 }, + { type: 'save' as CardBadgeType, count: 92 }, + { type: 'share' as CardBadgeType, count: 47 }, + ], + }, + { + id: 14, + imageSrc: '/ex.png', + title: '점심 추천', + href: '/tips/14', + badges: [ + { type: 'like' as CardBadgeType, count: 2 }, + { type: 'save' as CardBadgeType, count: 45 }, + { type: 'share' as CardBadgeType, count: 342 }, + ], + }, +]; diff --git a/src/features/notifications/components/NotificationCard.tsx b/src/features/notifications/components/NotificationCard.tsx new file mode 100644 index 0000000..25b5065 --- /dev/null +++ b/src/features/notifications/components/NotificationCard.tsx @@ -0,0 +1,37 @@ +export type NotificationCardProps = { + message: string; + timeLabel: string; + onClick?: () => void; + className?: string; + disabled?: boolean; +}; + +export default function NotificationCard({ + message, + timeLabel, + onClick, + className = '', + disabled = false, +}: NotificationCardProps) { + const clickable = !!onClick && !disabled; + + return ( +
+
+

{message}

+ + + {timeLabel} + +
+
+ ); +} diff --git a/src/features/search/components/HashtagList.tsx b/src/features/search/components/HashtagList.tsx new file mode 100644 index 0000000..73ea895 --- /dev/null +++ b/src/features/search/components/HashtagList.tsx @@ -0,0 +1,35 @@ +'use client'; + +export interface TagListProps { + title: string; + tags: string[]; + onClick?: (tag: string) => void; + className?: string; +} + +export default function HashtagList({ + title, + tags, + onClick, + className = '', +}: TagListProps) { + return ( +
+ {/* 제목 */} +

{title}

+ + {/* 태그 리스트 */} +
+ {tags.map((tag, idx) => ( + + ))} +
+
+ ); +} diff --git a/src/lib/utils/sortCards.ts b/src/lib/utils/sortCards.ts new file mode 100644 index 0000000..656a928 --- /dev/null +++ b/src/lib/utils/sortCards.ts @@ -0,0 +1,23 @@ +import type { CardListItem } from '@/common/components/CardList/CardList'; + +export type SortFilterType = 'ALL' | 'LIKE' | 'SAVE' | 'SHARE'; + +const strategies: Record< + SortFilterType, + (a: CardListItem, b: CardListItem) => number +> = { + LIKE: (a, b) => + Number(b.badges?.find(badge => badge.type === 'like')?.count ?? 0) - + Number(a.badges?.find(badge => badge.type === 'like')?.count ?? 0), + SAVE: (a, b) => + Number(b.badges?.find(badge => badge.type === 'save')?.count ?? 0) - + Number(a.badges?.find(badge => badge.type === 'save')?.count ?? 0), + SHARE: (a, b) => + Number(b.badges?.find(badge => badge.type === 'share')?.count ?? 0) - + Number(a.badges?.find(badge => badge.type === 'share')?.count ?? 0), + ALL: (a, b) => Number(a.id) - Number(b.id), +}; + +export function sortCards(items: CardListItem[], filter: SortFilterType) { + return [...items].sort(strategies[filter]); +} diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 0000000..fe53ea7 --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,26 @@ +// src/types/assets.d.ts + +declare module '*.png' { + const src: import('next/image').StaticImageData; + export default src; +} + +declare module '*.jpg' { + const src: import('next/image').StaticImageData; + export default src; +} + +declare module '*.jpeg' { + const src: import('next/image').StaticImageData; + export default src; +} + +declare module '*.webp' { + const src: import('next/image').StaticImageData; + export default src; +} + +declare module '*.svg' { + const src: string; + export default src; +} diff --git a/tsconfig.json b/tsconfig.json index c133409..cf37841 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,12 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "src/**/*.d.ts" + ], "exclude": ["node_modules"] }