From c581150adfcfa648cd75b32f8e8fcbbc2abe49e9 Mon Sep 17 00:00:00 2001 From: yshls Date: Wed, 14 Oct 2026 23:02:52 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=A9=94=EB=89=B4=EB=B0=94=EC=97=90=20K-?= =?UTF-8?q?=EB=A7=88=EB=A3=A8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20HugeIcons=20?= =?UTF-8?q?=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 데스크톱 네비게이션, 모바일 탭바/메뉴 및 지도 사이드 레일에 K-콘텐츠 탐색(/discover)을 위한 'K-마루' 항목 추가 - 사계절(seasons) 컴포넌트의 lucide-react 아이콘을 프로젝트 표준인 HugeIcons로 교체하고 lucide-react 의존성 제거 - K-컬처 탐색(k-culture-discovery) 및 소리마루 UI 컴포넌트 반영 - 헤더 정렬 테스트 갱신 (항목 5개 동기화) --- changelog.md | 3 + package.json | 1 - .../DiscoveryWorksPage.tsx | 184 +++++---- .../k-culture-discovery/KCultureDiscovery.tsx | 390 ++++++++++++++---- .../application/useCases.ts | 6 + .../k-culture-discovery/domain/types.ts | 8 +- .../infrastructure/discoverySource.ts | 10 +- .../infrastructure/fixtures.ts | 3 + .../components/DiscoveryPlaceCard.tsx | 253 +++++++----- .../components/DiscoveryPlacesSection.tsx | 74 ++-- .../components/DiscoveryWorkCard.tsx | 34 +- .../components/DiscoveryWorksSection.tsx | 37 +- .../components/KContentRelationCard.tsx | 30 +- .../components/PlaceDetailView.tsx | 111 ++++- .../presentation/components/TopicTabBar.tsx | 76 ++-- .../presentation/components/WorkTypeBadge.tsx | 2 + .../presentation/hooks/useDiscoveryPlaces.ts | 31 +- .../presentation/hooks/useDiscoveryWorks.ts | 29 +- .../hooks/useKContentRelations.ts | 2 +- .../presentation/hooks/usePlaceSave.ts | 39 ++ src/features/map/components/MapNavRail.tsx | 16 +- .../seasons/presentation/SeasonGateway.tsx | 5 +- .../presentation/SeasonScrollGuide.tsx | 15 +- .../sorimaru/SorimaruAutoSliceRail.tsx | 310 ++------------ .../components/Header/GlobalMobileTabs.tsx | 3 +- .../Header/Header.alignment.test.tsx | 2 +- src/shared/components/Header/Header.tsx | 8 +- 27 files changed, 1030 insertions(+), 652 deletions(-) create mode 100644 src/features/k-culture-discovery/presentation/hooks/usePlaceSave.ts diff --git a/changelog.md b/changelog.md index c1841735..60526f02 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,9 @@ Lightweight human-readable summary of meaningful repository changes. This does n ## Unreleased +- 메뉴바(상단 데스크톱 네비게이션, 모바일 하단 탭바, 모바일 메뉴 및 지도 사이드 레일)에 K-콘텐츠 탐색을 위한 `K-마루` 항목을 추가하고 헤더 정렬 테스트를 갱신했다. +- 사계절(`seasons`) 컴포넌트의 `lucide-react` 화살표 아이콘을 프로젝트 표준인 `HugeIcons`로 교체하고 `lucide-react` 의존성을 제거했다. + ## [v0.1.21] - 2026-10-13 - 사계 화면에서 장소 사진 미리보기를 마우스 hover가 아니라 탭(클릭)으로 바꿔, 모든 화면 크기에서 배경 사진이 깜빡이지 않게 했다. diff --git a/package.json b/package.json index c0cda8e5..bb68a061 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "framer-motion": "^12.42.2", - "lucide-react": "^1.29.0", "gsap": "^3.15.0", "lenis": "^1.3.25", "leva": "^0.10.1", diff --git a/src/features/k-culture-discovery/DiscoveryWorksPage.tsx b/src/features/k-culture-discovery/DiscoveryWorksPage.tsx index c4050d65..3b37c8ca 100644 --- a/src/features/k-culture-discovery/DiscoveryWorksPage.tsx +++ b/src/features/k-culture-discovery/DiscoveryWorksPage.tsx @@ -4,146 +4,185 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import styled from '@emotion/styled'; +import { motion, AnimatePresence } from 'framer-motion'; import { HugeiconsIcon } from '@hugeicons/react'; import { ArrowLeft01Icon, Film01Icon } from '@hugeicons/core-free-icons'; -import { meok, lightPalette, ringShadow, fontSize } from '@/design-system/tokens'; +import { meok, lightPalette, fontSize } from '@/design-system/tokens'; import DiscoveryWorksSection from './presentation/components/DiscoveryWorksSection'; import { useDiscoveryWorks } from './presentation/hooks/useDiscoveryWorks'; import type { WorkSummary, WorkType } from './domain/types'; +/* full-bleed → 자체 여백 */ const PageWrap = styled.div` - padding: 0 0 40px; - max-width: 680px; + max-width: 720px; margin: 0 auto; + padding: 0 16px 64px; `; const BackLink = styled(Link)` display: inline-flex; align-items: center; - gap: 6px; - font-size: ${fontSize.sm}; + gap: 5px; + font-size: ${fontSize.xs}; font-weight: 600; - color: ${meok[600]}; + color: ${meok[400]}; text-decoration: none; - margin: 16px 0 0; - transition: color 0.15s ease; + margin: 20px 0 0; + transition: color 0.14s ease; - &:hover { - color: ${lightPalette.juhong[500]}; + &:hover { color: ${meok[700]}; } + + [data-theme='dark'] & { + color: rgba(255,255,255,0.28); + &:hover { color: rgba(255,255,255,0.60); } } `; const PageHeader = styled.div` - margin: 20px 0 16px; + padding: 16px 0 20px; +`; + +const TitleRow = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; +`; + +const TitleIconWrap = styled.div` + width: 36px; + height: 36px; + border-radius: 10px; + background: ${lightPalette.juhong[50]}; + display: flex; + align-items: center; + justify-content: center; + color: ${lightPalette.juhong[500]}; + flex-shrink: 0; + + [data-theme='dark'] & { + background: rgba(255,85,0,0.12); + color: ${lightPalette.juhong[400]}; + } `; const PageTitle = styled.h1` font-family: var(--font-hanok); - font-size: clamp(22px, 3.5vw, 30px); + font-size: clamp(20px, 3.2vw, 26px); font-weight: 700; color: ${meok[900]}; - margin: 0 0 4px; + margin: 0; letter-spacing: -0.03em; [data-theme='dark'] & { - color: ${meok[100]}; + color: rgba(255,255,255,0.90); } `; -const PageSubtitle = styled.p` - font-size: ${fontSize.sm}; - color: ${meok[500]}; +const PageSub = styled.p` + font-size: ${fontSize.xs}; + color: ${meok[400]}; margin: 0; + letter-spacing: -0.01em; + + [data-theme='dark'] & { + color: rgba(255,255,255,0.28); + } `; const FilterRow = styled.div` display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; + gap: 6px; flex-wrap: wrap; + margin-bottom: 16px; `; -const FilterChip = styled.button<{ $active: boolean }>` +const Chip = styled.button<{ $active: boolean }>` padding: 6px 14px; border-radius: 9999px; border: none; - background: ${({ $active }) => ($active ? lightPalette.juhong[500] : meok[200])}; - color: ${({ $active }) => ($active ? '#ffffff' : meok[700])}; - box-shadow: ${({ $active }) => ($active ? ringShadow.light.chipActive : 'none')}; + background: ${({ $active }) => ($active ? lightPalette.juhong[500] : meok[100])}; + color: ${({ $active }) => ($active ? '#ffffff' : meok[600])}; font-size: ${fontSize.xs}; font-weight: ${({ $active }) => ($active ? 700 : 500)}; cursor: pointer; font-family: inherit; - transition: background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; + transition: background 0.14s ease, color 0.14s ease, transform 0.10s ease; &:hover { - background: ${({ $active }) => ($active ? lightPalette.juhong[400] : meok[300])}; - box-shadow: ${({ $active }) => ($active ? ringShadow.light.chipActive : ringShadow.light.chipHover)}; + background: ${({ $active }) => + $active ? lightPalette.juhong[400] : meok[200]}; + color: ${({ $active }) => ($active ? '#ffffff' : meok[800])}; } + &:active { transform: scale(0.96); } + [data-theme='dark'] & { - background: ${({ $active }) => ($active ? lightPalette.juhong[500] : meok[700])}; - color: ${({ $active }) => ($active ? '#ffffff' : meok[300])}; - box-shadow: ${({ $active }) => ($active ? ringShadow.dark.chipActive : 'none')}; + background: ${({ $active }) => + $active ? lightPalette.juhong[500] : 'rgba(255,255,255,0.07)'}; + color: ${({ $active }) => ($active ? '#ffffff' : 'rgba(255,255,255,0.45)')}; &:hover { - background: ${({ $active }) => ($active ? lightPalette.juhong[400] : meok[600])}; + background: ${({ $active }) => + $active ? lightPalette.juhong[400] : 'rgba(255,255,255,0.12)'}; + color: ${({ $active }) => ($active ? '#ffffff' : 'rgba(255,255,255,0.70)')}; } } `; const CountRow = styled.div` - display: flex; - align-items: center; margin-bottom: 12px; `; const MatchCount = styled.span` font-size: ${fontSize.xs}; - color: ${meok[500]}; + color: ${meok[400]}; font-weight: 500; + + [data-theme='dark'] & { + color: rgba(255,255,255,0.28); + } `; const UnavailableBox = styled.div` - padding: 48px 16px; + padding: 64px 24px; text-align: center; border-radius: 16px; background: ${meok[100]}; [data-theme='dark'] & { - background: ${meok[800]}; + background: rgba(255,255,255,0.04); } `; const UnavailableTitle = styled.p` - font-family: var(--font-hanok); - font-size: ${fontSize.base}; font-weight: 700; + font-size: ${fontSize.base}; color: ${meok[600]}; margin: 0 0 6px; - [data-theme='dark'] & { - color: ${meok[300]}; - } + [data-theme='dark'] & { color: rgba(255,255,255,0.40); } `; -const UnavailableDesc = styled.p` +const UnavailableSub = styled.p` font-size: ${fontSize.sm}; - color: ${meok[500]}; + color: ${meok[400]}; margin: 0; - line-height: 1.6; + line-height: 1.65; + + [data-theme='dark'] & { color: rgba(255,255,255,0.24); } `; const WORK_TYPE_LABELS: Record = { all: '전체', DRAMA: 'K-드라마', + ARTIST: '아티스트', MOVIE: '영화', VARIETY: '예능', MUSIC_VIDEO: 'MV', }; -const TYPE_FILTERS: Array = ['all', 'DRAMA', 'MOVIE', 'VARIETY', 'MUSIC_VIDEO']; +const TYPE_FILTERS: Array = ['all', 'DRAMA', 'ARTIST', 'MOVIE', 'VARIETY', 'MUSIC_VIDEO']; export default function DiscoveryWorksPage() { const router = useRouter(); @@ -153,65 +192,72 @@ export default function DiscoveryWorksPage() { type: activeType === 'all' ? undefined : activeType, }); - const handleWorkClick = (work: WorkSummary) => { + const handleWorkClick = (work: WorkSummary) => router.push(`/discover/works/${encodeURIComponent(work.workId)}`); - }; const serviceDown = isError && items.length === 0 && !isLoading; return ( - + 탐색마루 - - - 촬영지가 확인된 K-Culture 작품을 탐색하세요. + + + 작품 목록 + + 촬영지·방문지가 확인된 K-Culture 작품과 아티스트 {serviceDown ? ( 작품 목록 서비스 준비 중입니다 - - 콘텐츠 검수 및 서비스 준비가 완료되는 대로 공개될 예정입니다. - + 콘텐츠 검수 완료 후 공개 예정입니다. ) : ( <> {TYPE_FILTERS.map((type) => ( - setActiveType(type)} > {WORK_TYPE_LABELS[type]} - + ))} - {isLoading && items.length === 0 - ? '검색 중…' - : `작품 ${matchingCount.toLocaleString()}편`} + {isLoading && items.length === 0 ? '검색 중…' : `총 ${matchingCount.toLocaleString()}편`} - + + + + + )} diff --git a/src/features/k-culture-discovery/KCultureDiscovery.tsx b/src/features/k-culture-discovery/KCultureDiscovery.tsx index b13f928e..a21b883c 100644 --- a/src/features/k-culture-discovery/KCultureDiscovery.tsx +++ b/src/features/k-culture-discovery/KCultureDiscovery.tsx @@ -4,102 +4,297 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { motion, AnimatePresence } from 'framer-motion'; import { HugeiconsIcon } from '@hugeicons/react'; -import { Film01Icon, SparklesIcon } from '@hugeicons/core-free-icons'; -import { meok, lightPalette, fontSize } from '@/design-system/tokens'; +import { Film01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'; +import { meok, lightPalette, palette, fontSize } from '@/design-system/tokens'; import TopicTabBar from './presentation/components/TopicTabBar'; import DiscoveryPlacesSection from './presentation/components/DiscoveryPlacesSection'; import { useDiscoveryTopics } from './presentation/hooks/useDiscoveryTopics'; import { useDiscoveryPlaces } from './presentation/hooks/useDiscoveryPlaces'; -import type { DiscoveryPlaceSummary, TopicCode } from './domain/types'; +import type { DiscoveryPlaceSummary, TopicCode, FacetEntry } from './domain/types'; +const shimmer = keyframes` + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +`; + +/* ─── full-bleed page → 자체 여백 적용 ─── */ const PageWrap = styled.div` - padding: 0 0 40px; + max-width: 1140px; + margin: 0 auto; + padding: 0 16px 64px; + + @media (min-width: 768px) { + padding-top: 8px; + } `; -const PageHeader = styled.div` - margin-bottom: 20px; +/* ─── hero ─── */ +const Hero = styled.div` + padding: 28px 0 24px; `; -const PageTitle = styled.h1` - font-family: var(--font-hanok); - font-size: clamp(22px, 3.5vw, 30px); +const HeroBadge = styled.span` + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 9999px; + background: ${lightPalette.juhong[50]}; + color: ${lightPalette.juhong[600]}; + font-size: 11px; font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 12px; + + [data-theme='dark'] & { + background: rgba(255, 85, 0, 0.14); + color: ${lightPalette.juhong[300]}; + } +`; + +const HeroTitle = styled.h1` + font-family: var(--font-display); + font-size: clamp(24px, 4.5vw, 36px); + font-weight: 400; color: ${meok[900]}; - margin: 0 0 4px; + margin: 0 0 8px; letter-spacing: -0.03em; + line-height: 1.2; + white-space: nowrap; + + @media (max-width: 360px) { + white-space: normal; + font-size: 22px; + } + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.92); + } +`; + +const HeroAccent = styled.span` + color: ${lightPalette.juhong[500]}; [data-theme='dark'] & { - color: ${meok[100]}; + color: ${lightPalette.juhong[400]}; } `; -const PageSubtitle = styled.p` +const HeroSub = styled.p` font-size: ${fontSize.sm}; color: ${meok[500]}; - margin: 0 0 20px; + margin: 0; + line-height: 1.7; + max-width: 400px; + + [data-theme='dark'] & { + color: ${meok[500]}; + } `; -const CountRow = styled.div` +/* ─── stats bar (그림자 구분선, border 없음) ─── */ +const StatsBar = styled.div` display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; + padding: 10px 0 14px; + margin-bottom: 4px; +`; + +const StatsLeft = styled.div` + display: flex; + align-items: center; + gap: 7px; +`; + +const StatsDot = styled.span` + width: 7px; + height: 7px; + border-radius: 9999px; + background: ${lightPalette.cheongrok[500]}; + flex-shrink: 0; + + [data-theme='dark'] & { + background: ${lightPalette.cheongrok[400]}; + } `; -const MatchCount = styled.span` +const StatsText = styled.span` font-size: ${fontSize.xs}; color: ${meok[500]}; font-weight: 500; + letter-spacing: -0.01em; + + [data-theme='dark'] & { + color: ${meok[500]}; + } +`; + +const StatsBold = styled.strong` + color: ${meok[800]}; + font-weight: 700; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.85); + } +`; + +const SkeletonCount = styled.div` + width: 64px; + height: 13px; + border-radius: 5px; + background: linear-gradient( + 90deg, + ${meok[100]} 25%, #f3f3f1 50%, ${meok[100]} 75% + ); + background-size: 200% 100%; + animation: ${shimmer} 1.8s infinite ease-in-out; + + [data-theme='dark'] & { + background: linear-gradient( + 90deg, + rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.05) 75% + ); + background-size: 200% 100%; + } `; const WorksLink = styled(Link)` display: inline-flex; align-items: center; gap: 5px; + padding: 6px 14px 6px 11px; + border-radius: 9999px; + background: ${lightPalette.juhong[50]}; + color: ${lightPalette.juhong[600]}; font-size: ${fontSize.xs}; - font-weight: 600; - color: ${lightPalette.juhong[500]}; + font-weight: 700; text-decoration: none; - transition: color 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; &:hover { + background: ${lightPalette.juhong[100]}; color: ${lightPalette.juhong[700]}; } + + [data-theme='dark'] & { + background: rgba(255, 85, 0, 0.13); + color: ${lightPalette.juhong[300]}; + + &:hover { + background: rgba(255, 85, 0, 0.20); + color: ${lightPalette.juhong[200]}; + } + } +`; + +/* ─── facet filter chips ─── */ +const FacetRow = styled.div` + display: flex; + gap: 6px; + overflow-x: auto; + padding: 0 0 10px; + scrollbar-width: none; + &::-webkit-scrollbar { display: none; } +`; + +const FacetChip = styled.button<{ $active: boolean }>` + flex-shrink: 0; + padding: 5px 12px; + border-radius: 9999px; + border: none; + background: ${({ $active }) => + $active ? lightPalette.cheongrok[500] : meok[100]}; + color: ${({ $active }) => ($active ? '#ffffff' : meok[600])}; + font-size: 11px; + font-weight: ${({ $active }) => ($active ? 700 : 500)}; + cursor: pointer; + font-family: inherit; + white-space: nowrap; + transition: background 0.14s ease, color 0.14s ease, transform 0.10s ease; + + &:hover { + background: ${({ $active }) => + $active ? lightPalette.cheongrok[400] : meok[200]}; + } + + &:active { transform: scale(0.96); } + + [data-theme='dark'] & { + background: ${({ $active }) => + $active ? lightPalette.cheongrok[600] : 'rgba(255,255,255,0.07)'}; + color: ${({ $active }) => ($active ? '#ffffff' : 'rgba(255,255,255,0.45)')}; + + &:hover { + background: ${({ $active }) => + $active ? lightPalette.cheongrok[500] : 'rgba(255,255,255,0.12)'}; + } + } +`; + +const FacetCount = styled.span` + font-size: 10px; + font-weight: 600; + opacity: 0.65; + margin-left: 3px; `; +/* ─── unavailable ─── */ const UnavailableBox = styled.div` - padding: 48px 16px; + padding: 64px 24px; text-align: center; - border-radius: 16px; + border-radius: 20px; background: ${meok[100]}; - color: ${meok[500]}; + margin-top: 8px; + + [data-theme='dark'] & { + background: rgba(255, 255, 255, 0.04); + } +`; + +const UnavailableIcon = styled.div` + width: 52px; + height: 52px; + border-radius: 16px; + background: ${meok[200]}; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + color: ${meok[400]}; [data-theme='dark'] & { - background: ${meok[800]}; - color: ${meok[400]}; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.28); } `; const UnavailableTitle = styled.p` - font-family: var(--font-hanok); - font-size: ${fontSize.base}; font-weight: 700; - color: ${meok[600]}; + font-size: ${fontSize.base}; + color: ${meok[700]}; margin: 0 0 6px; [data-theme='dark'] & { - color: ${meok[300]}; + color: rgba(255, 255, 255, 0.55); } `; -const UnavailableDesc = styled.p` +const UnavailableSub = styled.p` font-size: ${fontSize.sm}; - color: ${meok[500]}; + color: ${meok[400]}; margin: 0; - line-height: 1.6; + line-height: 1.65; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.28); + } `; +/* ─── component ─── */ export interface KCultureDiscoveryProps { initialTopic?: TopicCode | 'all'; workId?: string; @@ -108,50 +303,47 @@ export interface KCultureDiscoveryProps { export default function KCultureDiscovery({ initialTopic = 'all', workId }: KCultureDiscoveryProps) { const router = useRouter(); const [activeTopic, setActiveTopic] = useState(initialTopic); + const [activeRegion, setActiveRegion] = useState(null); + const [activePlaceRole, setActivePlaceRole] = useState(null); const { topics, isLoading: topicsLoading } = useDiscoveryTopics(); - const { - items, - matchingCount, - isLoading, - isError, - hasMore, - loadMore, - } = useDiscoveryPlaces({ - topic: activeTopic === 'all' ? undefined : activeTopic, - workId, - }); + const { items, facets, matchingCount, isLoading, isError, hasMore, loadMore } = + useDiscoveryPlaces({ + topic: activeTopic === 'all' ? undefined : activeTopic, + regionCode: activeRegion ?? undefined, + placeRole: activePlaceRole ?? undefined, + workId, + }); const handleTopicChange = (code: TopicCode | 'all') => { setActiveTopic(code); + setActiveRegion(null); + setActivePlaceRole(null); }; - - const handlePlaceClick = (place: DiscoveryPlaceSummary) => { + const handlePlaceClick = (place: DiscoveryPlaceSummary) => router.push(`/discover/places/${encodeURIComponent(place.placeId)}`); - }; - // When the service is unavailable (flag off, 404/503), show a single friendly state - // instead of a broken partial UI or unreviewed fixture cards. + const regionFacets: FacetEntry[] = facets?.regionCode?.filter((f) => f.count > 0) ?? []; + const roleFacets: FacetEntry[] = facets?.placeRole?.filter((f) => f.count > 0) ?? []; + const hasFacets = regionFacets.length > 0 || roleFacets.length > 0; + const serviceDown = isError && items.length === 0 && !isLoading; return ( - - - - - 전통 공간·문화유산부터 드라마·영화·MV 촬영지까지, 근거가 확인된 K-Culture 장소를 탐색하세요. - - + + K-Culture Discovery + 공간이 된 문화의 기록 + 드라마·영화 촬영지부터 아티스트 성지까지, 근거가 확인된 K-Culture 장소를 탐색하세요. + {serviceDown ? ( + 탐색 서비스 준비 중입니다 - - 콘텐츠 검수 및 서비스 준비가 완료되는 대로 공개될 예정입니다. - + 콘텐츠 검수 완료 후 공개 예정입니다. ) : ( <> @@ -162,26 +354,70 @@ export default function KCultureDiscovery({ initialTopic = 'all', workId }: KCul onChange={handleTopicChange} /> - - - {isLoading && items.length === 0 - ? '검색 중…' - : `장소 ${matchingCount.toLocaleString()}개`} - + {hasFacets && ( + + {regionFacets.map((f) => ( + setActiveRegion(activeRegion === f.code ? null : f.code)} + > + {f.labelKo ?? f.code} + {f.count} + + ))} + {roleFacets.map((f) => ( + setActivePlaceRole(activePlaceRole === f.code ? null : f.code)} + > + {f.labelKo ?? f.code} + {f.count} + + ))} + + )} + + + + + + {isLoading && items.length === 0 ? ( + + ) : ( + <>장소 {matchingCount.toLocaleString()}곳 + )} + + + - - - - + + + + + + + )} diff --git a/src/features/k-culture-discovery/application/useCases.ts b/src/features/k-culture-discovery/application/useCases.ts index 07916719..a66349de 100644 --- a/src/features/k-culture-discovery/application/useCases.ts +++ b/src/features/k-culture-discovery/application/useCases.ts @@ -5,6 +5,8 @@ import { fetchDiscoveryWorks, fetchDiscoveryWorkDetail, fetchKContents, + savePlace, + unsavePlace, } from '../infrastructure/discoverySource'; import type { DiscoveryTopic, @@ -46,3 +48,7 @@ export async function getDiscoveryWorkDetail(workId: string): Promise { return fetchKContents(placeId); } + +export async function togglePlaceSave(placeId: string, currentlySaved: boolean): Promise { + return currentlySaved ? unsavePlace(placeId) : savePlace(placeId); +} diff --git a/src/features/k-culture-discovery/domain/types.ts b/src/features/k-culture-discovery/domain/types.ts index 3b12b0c8..7f8e330a 100644 --- a/src/features/k-culture-discovery/domain/types.ts +++ b/src/features/k-culture-discovery/domain/types.ts @@ -4,11 +4,12 @@ export type TopicCode = | 'TRADITIONAL_FOOD_TEA' | 'K_DRAMA' | 'K_MOVIE' - | 'K_POP_MV'; + | 'K_POP_MV' + | 'K_ARTIST'; -export type WorkType = 'DRAMA' | 'MOVIE' | 'VARIETY' | 'MUSIC_VIDEO'; +export type WorkType = 'DRAMA' | 'MOVIE' | 'VARIETY' | 'MUSIC_VIDEO' | 'ARTIST'; -export type RelationType = 'FILMING_LOCATION'; +export type RelationType = 'FILMING_LOCATION' | 'VISIT_SPOT'; export interface Region { code: string; @@ -84,6 +85,7 @@ export interface DiscoveryPlaceDetail { topics: TopicCode[]; images: string[]; odii: OdiiInfo; + placeSummaryPoints: string[]; kContents: KContentsInfo; saveAvailable: boolean; savedByMe: boolean | null; diff --git a/src/features/k-culture-discovery/infrastructure/discoverySource.ts b/src/features/k-culture-discovery/infrastructure/discoverySource.ts index c1376d60..adee8906 100644 --- a/src/features/k-culture-discovery/infrastructure/discoverySource.ts +++ b/src/features/k-culture-discovery/infrastructure/discoverySource.ts @@ -1,4 +1,4 @@ -import { apiGet } from '@/lib/api/client'; +import { apiGet, apiPost, apiDelete } from '@/lib/api/client'; import type { DiscoveryTopic, DiscoveryPlaceDetail, @@ -80,3 +80,11 @@ export async function fetchKContents(placeId: string): Promise { if (res && Array.isArray(res.items)) return res; throw new Error('Invalid k-contents response'); } + +export async function savePlace(placeId: string): Promise { + await apiPost(`/api/v1/places/${encodeURIComponent(placeId)}/saves`); +} + +export async function unsavePlace(placeId: string): Promise { + await apiDelete(`/api/v1/places/${encodeURIComponent(placeId)}/saves`); +} diff --git a/src/features/k-culture-discovery/infrastructure/fixtures.ts b/src/features/k-culture-discovery/infrastructure/fixtures.ts index 9dd529fd..44f897d4 100644 --- a/src/features/k-culture-discovery/infrastructure/fixtures.ts +++ b/src/features/k-culture-discovery/infrastructure/fixtures.ts @@ -231,6 +231,7 @@ const FIXTURE_PLACE_DETAILS: Record = { placeRoles: [{ code: 'PALACE', labelKo: '궁궐' }], topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], images: ['https://tong.visitkorea.or.kr/cms/resource/64/1939964_image2_1.jpg'], + placeSummaryPoints: ['1395년 창건', '조선 정궁', '유네스코 세계유산 다수 보유'], odii: { linked: true, storyCount: 3 }, kContents: { relationCount: 4, href: '/api/v1/places/p-gyeongbok/k-contents' }, saveAvailable: true, @@ -253,6 +254,7 @@ const FIXTURE_PLACE_DETAILS: Record = { placeRoles: [{ code: 'FOLK_VILLAGE', labelKo: '민속마을' }], topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA', 'K_MOVIE'], images: ['https://tong.visitkorea.or.kr/cms/resource/82/2567982_image2_1.jpg'], + placeSummaryPoints: ['600년 집성촌', 'UNESCO 세계문화유산', '낙동강 물돌이 지형'], odii: { linked: false, storyCount: 0 }, kContents: { relationCount: 3, href: '/api/v1/places/p-andong-hahoe/k-contents' }, saveAvailable: true, @@ -506,6 +508,7 @@ export function getFixturePlaceDetail(placeId: string): DiscoveryPlaceDetail | n topics: summary.topics, images: summary.placeImage ? [summary.placeImage] : [], odii: { linked: summary.odiiLinked, storyCount: 0 }, + placeSummaryPoints: [], kContents: { relationCount: summary.matchingWorkCount, href: `/api/v1/places/${placeId}/k-contents`, diff --git a/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx b/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx index b0b786cb..6bd609c2 100644 --- a/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx +++ b/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx @@ -5,25 +5,38 @@ import styled from '@emotion/styled'; import { keyframes } from '@emotion/react'; import { motion } from 'framer-motion'; import { HugeiconsIcon } from '@hugeicons/react'; -import { - MapPinIcon, - AddToListIcon, - Building04Icon, -} from '@hugeicons/core-free-icons'; +import { MapPinIcon, Building04Icon, Layers01Icon } from '@hugeicons/core-free-icons'; import { meok, lightPalette, ringShadow, fontSize } from '@/design-system/tokens'; import WorkTypeBadge from './WorkTypeBadge'; import type { DiscoveryPlaceSummary } from '../../domain/types'; -const shimmerAnim = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +/* 이미지 없는 장소를 위한 그래디언트 팔레트 */ +const FALLBACK_GRADIENTS = [ + `linear-gradient(135deg, #e8d5c0 0%, #c9b08a 100%)`, + `linear-gradient(135deg, #c8d8c0 0%, #8aad85 100%)`, + `linear-gradient(135deg, #c8d0e0 0%, #8a9dc4 100%)`, + `linear-gradient(135deg, #ddc8d8 0%, #b08ab0 100%)`, + `linear-gradient(135deg, #d8e0c8 0%, #9aaa7a 100%)`, +]; + +function getFallbackGradient(placeId: string): string { + let hash = 0; + for (let i = 0; i < placeId.length; i++) { + hash = (hash * 31 + placeId.charCodeAt(i)) % FALLBACK_GRADIENTS.length; + } + return FALLBACK_GRADIENTS[hash]; +} + +const shimmer = keyframes` + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } `; const Card = styled(motion.button)` position: relative; display: flex; flex-direction: column; - border-radius: 18px; + border-radius: 14px; overflow: hidden; background: #ffffff; border: none; @@ -31,24 +44,30 @@ const Card = styled(motion.button)` cursor: pointer; text-align: left; width: 100%; - box-shadow: ${ringShadow.light.cardElevated}; - transition: box-shadow 0.22s ease; + box-shadow: ${ringShadow.light.card}; + transition: box-shadow 0.18s ease, transform 0.18s cubic-bezier(0.16,1,0.3,1); [data-theme='dark'] & { - background: ${meok[800]}; - box-shadow: ${ringShadow.dark.cardElevated}; + background: #1e2330; + box-shadow: ${ringShadow.dark.card}; } &:hover { - box-shadow: ${ringShadow.light.cardLift}; + box-shadow: ${ringShadow.light.cardHoverGlow}; + transform: translateY(-2px); [data-theme='dark'] & { - box-shadow: ${ringShadow.dark.cardLift}; + box-shadow: ${ringShadow.dark.cardHoverGlow}; } } - &:hover img { - transform: scale(1.05); + &:active { + transform: scale(0.985) translateY(0); + box-shadow: none; + } + + &:hover .place-img { + transform: scale(1.04); } `; @@ -62,67 +81,78 @@ const PlaceImage = styled.img` width: 100%; height: 100%; object-fit: cover; - transition: transform 0.36s cubic-bezier(0.16, 1, 0.3, 1); -`; - -const ImageScrim = styled.div` - position: absolute; - inset: 0; - background: linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.08) 55%); + transition: transform 0.38s cubic-bezier(0.16, 1, 0.3, 1); `; -const FallbackImage = styled.div` +const FallbackWrap = styled.div<{ $gradient: string }>` width: 100%; height: 100%; - background: ${meok[200]}; + background: ${({ $gradient }) => $gradient}; display: flex; align-items: center; justify-content: center; - color: ${meok[400]}; [data-theme='dark'] & { - background: ${meok[700]}; - color: ${meok[500]}; + filter: brightness(0.55) saturate(0.7); } `; -const BadgeRow = styled.div` - position: absolute; - top: 10px; - left: 10px; - right: 10px; +const FallbackIcon = styled.div` + width: 42px; + height: 42px; + border-radius: 12px; + background: rgba(255,255,255,0.32); display: flex; align-items: center; + justify-content: center; + color: rgba(255,255,255,0.72); +`; + +const ImageScrim = styled.div` + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.12) 45%, transparent 70%); +`; + +const TopRow = styled.div` + position: absolute; + top: 9px; + left: 9px; + right: 9px; + display: flex; + align-items: flex-start; justify-content: space-between; `; -const WorkCountBadge = styled.span` +const MultiPill = styled.span` display: inline-flex; align-items: center; - gap: 4px; - padding: 4px 10px; + gap: 3px; + padding: 3px 8px; border-radius: 9999px; - background: ${lightPalette.juhong[500]}; - color: #ffffff; - font-size: 11px; - font-weight: 600; + background: rgba(0,0,0,0.48); + backdrop-filter: blur(6px); + color: rgba(255,255,255,0.88); + font-size: 10px; + font-weight: 700; + margin-left: auto; `; -const PlaceRolePill = styled.span` +const RolePill = styled.span` position: absolute; - bottom: 10px; - left: 10px; - padding: 3px 10px; + bottom: 9px; + left: 9px; + padding: 3px 9px; border-radius: 9999px; - background: rgba(255,255,255,0.18); - backdrop-filter: blur(6px); - color: rgba(255,255,255,0.92); - font-size: 11px; + background: rgba(255,255,255,0.15); + backdrop-filter: blur(8px); + color: rgba(255,255,255,0.86); + font-size: 10px; font-weight: 500; `; const Body = styled.div` - padding: 12px 14px 14px; + padding: 10px 12px 13px; display: flex; flex-direction: column; gap: 4px; @@ -130,82 +160,90 @@ const Body = styled.div` const PlaceName = styled.h3` font-family: var(--font-hanok); - font-size: ${fontSize.base}; + font-size: ${fontSize.sm}; font-weight: 700; color: ${meok[900]}; margin: 0; line-height: 1.3; - letter-spacing: -0.02em; + letter-spacing: -0.025em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; [data-theme='dark'] & { - color: ${meok[100]}; + color: rgba(255,255,255,0.88); } `; -const RegionRow = styled.div` +const Region = styled.div` display: flex; align-items: center; gap: 3px; - color: ${meok[500]}; - font-size: ${fontSize.xs}; + color: ${meok[400]}; + font-size: 11px; font-weight: 500; + + [data-theme='dark'] & { + color: rgba(255,255,255,0.30); + } `; -const WorkTitle = styled.p` - font-size: ${fontSize.xs}; - color: ${lightPalette.juhong[600]}; - margin: 0; +const WorkRow = styled.p` + font-size: 11px; + color: ${lightPalette.juhong[500]}; + font-weight: 600; + margin: 2px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-weight: 500; [data-theme='dark'] & { color: ${lightPalette.juhong[400]}; } `; +/* ─── skeleton ─── */ export const SkeletonCard = styled.div` - border-radius: 18px; + border-radius: 14px; overflow: hidden; - background: ${meok[200]}; - box-shadow: ${ringShadow.light.cardElevated}; + background: #ffffff; + box-shadow: ${ringShadow.light.card}; [data-theme='dark'] & { - background: #252422; - box-shadow: ${ringShadow.dark.cardElevated}; + background: #1e2330; + box-shadow: ${ringShadow.dark.card}; } `; -const SkeletonImageWrap = styled.div` +const SkImgWrap = styled.div` aspect-ratio: 4 / 3; - background: linear-gradient(90deg, #e4e4e2 25%, #dcdcd9 50%, #e4e4e2 75%); + background: linear-gradient(90deg, ${meok[100]} 25%, #f0efec 50%, ${meok[100]} 75%); background-size: 200% 100%; - animation: ${shimmerAnim} 1.6s infinite ease-in-out; + animation: ${shimmer} 1.8s infinite ease-in-out; [data-theme='dark'] & { - background: linear-gradient(90deg, #32302d 25%, #3d3a36 50%, #32302d 75%); + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); background-size: 200% 100%; } `; -const SkeletonBody = styled.div` - padding: 12px 14px 14px; +const SkBody = styled.div` + padding: 10px 12px 13px; display: flex; flex-direction: column; gap: 6px; `; -const SkeletonLine = styled.div<{ $w?: string; $h?: string }>` +const SkLine = styled.div<{ $w?: string; $h?: string }>` width: ${({ $w }) => $w ?? '100%'}; - height: ${({ $h }) => $h ?? '14px'}; - border-radius: 6px; - background: linear-gradient(90deg, #e4e4e2 25%, #dcdcd9 50%, #e4e4e2 75%); + height: ${({ $h }) => $h ?? '13px'}; + border-radius: 5px; + background: linear-gradient(90deg, ${meok[100]} 25%, #f0efec 50%, ${meok[100]} 75%); background-size: 200% 100%; - animation: ${shimmerAnim} 1.6s infinite ease-in-out; + animation: ${shimmer} 1.8s infinite ease-in-out; [data-theme='dark'] & { - background: linear-gradient(90deg, #32302d 25%, #3d3a36 50%, #32302d 75%); + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); background-size: 200% 100%; } `; @@ -213,12 +251,12 @@ const SkeletonLine = styled.div<{ $w?: string; $h?: string }>` export function DiscoveryPlaceCardSkeleton() { return ( - - - - - - + + + + + + ); } @@ -232,51 +270,48 @@ export interface DiscoveryPlaceCardProps { export default function DiscoveryPlaceCard({ place, index = 0, onClick }: DiscoveryPlaceCardProps) { const primaryRole = place.placeRoles[0]; const relation = place.featuredRelation; + const fallbackGradient = getFallbackGradient(place.placeId); return ( onClick?.(place)} > {place.placeImage ? ( - + ) : ( - - + + + + )} - - {relation && ( - - )} + + {relation && } {place.matchingWorkCount > 1 && ( - - + + )} - + - {primaryRole && ( - {primaryRole.labelKo} - )} + {primaryRole && {primaryRole.labelKo}} {place.name} - - - {relation && ( - {relation.workTitle} - )} + + {relation && {relation.workTitle}} ); diff --git a/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx b/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx index ac7ee178..7e0b5b44 100644 --- a/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx +++ b/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx @@ -4,43 +4,49 @@ import React from 'react'; import styled from '@emotion/styled'; import { HugeiconsIcon } from '@hugeicons/react'; import { ArrowDown01Icon } from '@hugeicons/core-free-icons'; -import { meok, lightPalette, ringShadow, fontSize } from '@/design-system/tokens'; +import { meok, lightPalette, fontSize } from '@/design-system/tokens'; import DiscoveryPlaceCard, { DiscoveryPlaceCardSkeleton } from './DiscoveryPlaceCard'; import type { DiscoveryPlaceSummary } from '../../domain/types'; const Grid = styled.div` display: grid; grid-template-columns: 1fr 1fr; - gap: 12px; + gap: 10px; @media (min-width: 640px) { grid-template-columns: repeat(3, 1fr); + gap: 12px; } - @media (max-width: 400px) { + @media (max-width: 360px) { grid-template-columns: 1fr; } `; const EmptyBox = styled.div` grid-column: 1 / -1; - padding: 48px 0; + padding: 56px 0; text-align: center; - color: ${meok[500]}; + color: ${meok[400]}; font-size: ${fontSize.sm}; + + [data-theme='dark'] & { + color: rgba(255,255,255,0.24); + } `; const ErrorBox = styled.div` grid-column: 1 / -1; - padding: 32px 16px; + padding: 40px 20px; text-align: center; + border-radius: 14px; + background: ${meok[100]}; color: ${meok[500]}; font-size: ${fontSize.sm}; - border-radius: 12px; - background: ${meok[100]}; [data-theme='dark'] & { - background: ${meok[800]}; + background: rgba(255,255,255,0.05); + color: rgba(255,255,255,0.30); } `; @@ -50,30 +56,35 @@ const LoadMoreBtn = styled.button` justify-content: center; gap: 6px; width: 100%; - margin-top: 4px; + margin-top: 10px; padding: 14px; border-radius: 12px; border: none; background: ${meok[100]}; color: ${meok[600]}; - font-size: ${fontSize.sm}; + font-size: ${fontSize.xs}; font-weight: 600; cursor: pointer; font-family: inherit; - transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; + transition: background 0.14s ease, color 0.14s ease, transform 0.10s ease; &:hover { background: ${meok[200]}; - color: ${lightPalette.juhong[500]}; - box-shadow: ${ringShadow.light.chipHover}; + color: ${meok[800]}; + } + + &:active { + transform: scale(0.98); + background: ${meok[200]}; } [data-theme='dark'] & { - background: ${meok[800]}; - color: ${meok[300]}; + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.45); &:hover { - background: ${meok[700]}; + background: rgba(255,255,255,0.10); + color: rgba(255,255,255,0.70); } } `; @@ -88,19 +99,12 @@ export interface DiscoveryPlacesSectionProps { } export default function DiscoveryPlacesSection({ - items, - isLoading, - isError, - hasMore, - onLoadMore, - onPlaceClick, + items, isLoading, isError, hasMore, onLoadMore, onPlaceClick, }: DiscoveryPlacesSectionProps) { if (isLoading && items.length === 0) { return ( - {[...Array(6)].map((_, i) => ( - - ))} + {[...Array(6)].map((_, i) => )} ); } @@ -108,9 +112,7 @@ export default function DiscoveryPlacesSection({ if (isError && items.length === 0) { return ( - - 탐색 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요. - + 탐색 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요. ); } @@ -118,7 +120,7 @@ export default function DiscoveryPlacesSection({ if (!isLoading && items.length === 0) { return ( - 이 조건에 맞는 장소가 없습니다. 다른 주제나 필터를 선택해 보세요. + 이 조건에 맞는 장소가 없습니다. ); } @@ -127,20 +129,14 @@ export default function DiscoveryPlacesSection({ <> {items.map((place, i) => ( - + ))} - {isLoading && - [...Array(3)].map((_, i) => )} + {isLoading && [...Array(3)].map((_, i) => )} {hasMore && !isLoading && ( - )} diff --git a/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx b/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx index 124f8c3e..fe5e19fe 100644 --- a/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx +++ b/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx @@ -5,7 +5,7 @@ import styled from '@emotion/styled'; import { keyframes } from '@emotion/react'; import { motion } from 'framer-motion'; import { HugeiconsIcon } from '@hugeicons/react'; -import { MapPinIcon, ClapperboardIcon } from '@hugeicons/core-free-icons'; +import { MapPinIcon, ClapperboardIcon, HeadphonesIcon } from '@hugeicons/core-free-icons'; import { meok, lightPalette, ringShadow, fontSize } from '@/design-system/tokens'; import WorkTypeBadge from './WorkTypeBadge'; import type { WorkSummary } from '../../domain/types'; @@ -27,19 +27,30 @@ const Card = styled(motion.button)` cursor: pointer; text-align: left; width: 100%; - box-shadow: ${ringShadow.light.cardElevated}; - transition: box-shadow 0.22s ease; + box-shadow: ${ringShadow.light.card}; + transition: background 0.14s ease, box-shadow 0.14s ease, transform 0.10s ease; [data-theme='dark'] & { - background: ${meok[800]}; - box-shadow: ${ringShadow.dark.cardElevated}; + background: #1e2330; + box-shadow: ${ringShadow.dark.card}; } &:hover { - box-shadow: ${ringShadow.light.cardLift}; + background: ${meok[100]}; + box-shadow: ${ringShadow.light.cardHoverGlow}; + + [data-theme='dark'] & { + background: rgba(255,255,255,0.04); + box-shadow: ${ringShadow.dark.cardHoverGlow}; + } + } + + &:active { + transform: scale(0.985); + background: ${meok[200]}; [data-theme='dark'] & { - box-shadow: ${ringShadow.dark.cardLift}; + background: rgba(255,255,255,0.07); } } `; @@ -88,7 +99,7 @@ const Title = styled.h3` text-overflow: ellipsis; [data-theme='dark'] & { - color: ${meok[100]}; + color: rgba(255,255,255,0.88); } `; @@ -213,7 +224,12 @@ export default function DiscoveryWorkCard({ work, index = 0, onClick }: Discover {work.workArtwork ? ( ) : ( -