diff --git a/changelog.md b/changelog.md index c1841735..7adb29d1 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,18 @@ Lightweight human-readable summary of meaningful repository changes. This does n ## Unreleased +- K-마루(`KCultureDiscovery`) 장소 카드 및 페이지 디자인을 전면 개편했다: + - 주제 탭 아래 중복되어 시각적 피로감을 주던 세부 서브 필터 영역(콘텐츠 유형, 지역, 장소 유형 칩 묶음)을 전면 제거하여, 주제 탭과 장소 카드가 시원하고 여백감 넘치게 이어지도록 페이지 구성을 정돈했다. + - 장소 목록의 과도한 세로 스크롤을 방지하기 위해 6개 단위 그리드 페이지네이션(`DiscoveryPlacesSection`)을 구현하고, `ringShadow` 토큰 기반의 세련된 페이지 버튼(`[<] [1] [2] ... [>]`)과 페이지 전환 시 부드러운 상단 스크롤을 지원했다. + - 장소 목록 상단의 '장소 N곳' 개수 표시 및 라이브 닷 인디케이터를 삭제하여 시각적 군더더기를 없애고, '작품 목록' 버튼만 우측에 단정하게 배치했다. + - 온마루 디자인 시스템 원칙에 맞추어 장소 카드, 스켈레톤, 필터 칩, 네비게이션 탭의 불필요한 외곽선(`border`)을 일체 배제하고, 자연스러운 입체감을 위해 온마루 표준 `ringShadow` 토큰(`card`, `cardHoverGlow`, `button`, `chipActive` 등)으로 통일했다. + - 장소 카드에 K-콘텐츠 전용 인라인 뱃지(`WorkRelationBadge`)를 도입해 작품명과 촬영지/방문지 역할을 세련된 미니 블록으로 시각화했다. + - 지역명이 비어 있거나 없는 경우 빈 핀 아이콘만 노출되던 문제를 해결하고, 한국관광공사 지역 코드 매핑을 통해 정규화된 2글자 지역명을 렌더링하도록 개선했다. + - 이미지 미제공 장소의 폴백을 은은한 수묵·한지 질감과 단정한 엠블럼의 고품격 비주얼로 업그레이드했다. +- 메뉴바(상단 데스크톱 네비게이션, 모바일 하단 탭바, 모바일 메뉴 및 지도 사이드 레일)에 K-콘텐츠 탐색을 위한 `K-마루` 항목을 추가하고 헤더 정렬 테스트를 갱신했다. +- 지도 화면의 네비게이션 레일(`MapNavRail`) 메뉴 순서를 홈 메뉴바와 동일하게 `홈 → 한옥마루 → K-마루 → 소리마루 → 지도마루`로 동기화하고, 지도마루 아이콘을 홈과 동일한 `MapIcon`으로 일치시켰으며 `모음마루`를 하단 개인화 영역으로 분리 정돈했다. +- 사계절(`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/app/api/discovery/place-image/route.ts b/src/app/api/discovery/place-image/route.ts new file mode 100644 index 00000000..37746ead --- /dev/null +++ b/src/app/api/discovery/place-image/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; + +// ponytail: 백엔드가 placeImage: null 반환하는 동안 TourAPI에서 직접 가져오는 임시 브릿지 +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const contentId = searchParams.get('contentId'); + + if (!contentId || !/^\d+$/.test(contentId)) { + return NextResponse.json({ imageUrl: null }); + } + + const apiKey = process.env.TOUR_API_KEY; + if (!apiKey) return NextResponse.json({ imageUrl: null }); + + try { + const qs = new URLSearchParams({ + serviceKey: apiKey, + contentId, + defaultYN: 'N', + firstImageYN: 'Y', + areacodeYN: 'N', + catcodeYN: 'N', + addrinfoYN: 'N', + mapinfoYN: 'N', + overviewYN: 'N', + numOfRows: '1', + pageNo: '1', + MobileOS: 'ETC', + MobileApp: 'OnMaru', + _type: 'json', + }); + + const res = await fetch( + `https://apis.data.go.kr/B551011/KorService2/detailCommon2?${qs}`, + { signal: AbortSignal.timeout(5000) }, + ); + + if (!res.ok) return NextResponse.json({ imageUrl: null }); + + const data = await res.json(); + const raw = data?.response?.body?.items?.item; + const item = Array.isArray(raw) ? raw[0] : raw; + const imageUrl = item?.firstimage || item?.firstimage2 || null; + + return NextResponse.json( + { imageUrl: imageUrl ? imageUrl.replace(/^http:\/\//i, 'https://') : null }, + { headers: { 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600' } }, + ); + } catch { + return NextResponse.json({ imageUrl: null }); + } +} diff --git a/src/features/hanok-archive/HanokArchive.tsx b/src/features/hanok-archive/HanokArchive.tsx index dce66df9..98831eac 100644 --- a/src/features/hanok-archive/HanokArchive.tsx +++ b/src/features/hanok-archive/HanokArchive.tsx @@ -10,7 +10,6 @@ import HanokStayAccordion from '@/features/hanok-archive/sections/HanokStayAccor import HanokMap from '@/features/hanok-archive/sections/HanokMap'; import HanokPolaroidClothesline from '@/features/hanok-archive/sections/HanokPolaroidClothesline'; import HanokSideIndex from '@/features/hanok-archive/components/HanokSideIndex'; -import KCultureThemeFeed from '@/features/hanok-archive/components/KCultureThemeFeed'; import HanokManifestoCta from '@/features/hanok-archive/sections/HanokManifestoCta'; import HanokStructureCards from '@/features/hanok-archive/structure/HanokStructureCards'; import type { Village, VillageMeta } from '@/features/hanok-archive/types'; @@ -85,21 +84,6 @@ const SectionContainer = styled.div` } `; -const KCultureSectionContainer = styled(SectionContainer)` - padding-top: clamp(14px, 2vw, 24px); - padding-bottom: clamp(14px, 2vw, 24px); - - @media (max-width: 1024px) { - padding-top: clamp(12px, 2vw, 20px); - padding-bottom: clamp(12px, 2vw, 20px); - } - - @media (max-width: 640px) { - padding-top: clamp(10px, 2vw, 16px); - padding-bottom: clamp(10px, 2vw, 16px); - } -`; - const StyledVesselReveal = styled(VesselReveal)` width: 100%; `; @@ -120,10 +104,6 @@ const paperGround = css` -const EditorialSection = styled.div` - padding-top: clamp(32px, 3.5vh, 46px); -`; - const ChapterBreak = styled.div` padding-top: clamp(80px, 9vh, 124px); @@ -298,15 +278,6 @@ export default function HanokArchive({ - {} - - - - - - - - { diff --git a/src/features/hanok-archive/components/HanokSideIndex.tsx b/src/features/hanok-archive/components/HanokSideIndex.tsx index c3624e92..5e2f1538 100644 --- a/src/features/hanok-archive/components/HanokSideIndex.tsx +++ b/src/features/hanok-archive/components/HanokSideIndex.tsx @@ -12,7 +12,6 @@ import { HANOK_REVEAL_SECTIONS } from '@/features/hanok-archive/hanokSectionReve const SIDE_INDEX_SECTIONS = [ - { id: 'hanok-kculture-themes', label: '스크린 속 한옥' }, { id: HANOK_REVEAL_SECTIONS.structure, label: '3D 구조 & 일조' }, { id: HANOK_REVEAL_SECTIONS.grid, label: '전국 한옥 도감' }, { id: HANOK_REVEAL_SECTIONS.stay, label: '지역별 한옥 스테이' }, diff --git a/src/features/k-culture-discovery/DiscoveryWorksPage.tsx b/src/features/k-culture-discovery/DiscoveryWorksPage.tsx index c4050d65..e031dba8 100644 --- a/src/features/k-culture-discovery/DiscoveryWorksPage.tsx +++ b/src/features/k-culture-discovery/DiscoveryWorksPage.tsx @@ -4,146 +4,189 @@ 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: 1140px; margin: 0 auto; + padding: 0 16px 64px; + + @media (min-width: 768px) { + padding-top: 66px; + } `; 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 +196,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..44ebd96a 100644 --- a/src/features/k-culture-discovery/KCultureDiscovery.tsx +++ b/src/features/k-culture-discovery/KCultureDiscovery.tsx @@ -1,103 +1,240 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } 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 { Film01Icon, SparklesIcon } from '@hugeicons/core-free-icons'; -import { meok, lightPalette, fontSize } from '@/design-system/tokens'; +import { Search01Icon } from '@hugeicons/core-free-icons'; +import { meok, fontSize, ringShadow } from '@/design-system/tokens'; import TopicTabBar from './presentation/components/TopicTabBar'; import DiscoveryPlacesSection from './presentation/components/DiscoveryPlacesSection'; +import FeaturedPlaceCard from './presentation/components/FeaturedPlaceCard'; +import HorizontalSection from './presentation/components/HorizontalSection'; import { useDiscoveryTopics } from './presentation/hooks/useDiscoveryTopics'; import { useDiscoveryPlaces } from './presentation/hooks/useDiscoveryPlaces'; -import type { DiscoveryPlaceSummary, TopicCode } from './domain/types'; +import type { DiscoveryPlaceSummary, TopicCode, WorkType } from './domain/types'; + +const REGION_MAP: Record = { + '1': '서울', '11': '서울', '2': '인천', '28': '인천', + '3': '대전', '30': '대전', '4': '대구', '27': '대구', + '5': '광주', '29': '광주', '6': '부산', '26': '부산', + '7': '울산', '8': '세종', '31': '경기', '41': '경기', + '32': '강원', '42': '강원', '33': '충북', '43': '충북', + '34': '충남', '44': '충남', '35': '전북', '45': '전북', + '36': '전남', '46': '전남', '37': '경북', '47': '경북', + '38': '경남', '48': '경남', '39': '제주', '50': '제주', +}; + +const TYPE_LABEL: Partial> = { + DRAMA: '드라마 촬영지', + MOVIE: '영화 속 공간', + MUSIC_VIDEO: 'MV 배경', + ARTIST: '아티스트 공간', + VARIETY: '예능 명소', +}; const PageWrap = styled.div` - padding: 0 0 40px; + width: min(calc(100% - 40px), 1140px); + max-width: 1140px; + margin: 0 auto; + padding-top: 64px; + padding-bottom: 80px; + + @media (min-width: 768px) { + padding-top: 80px; + } + + @media (max-width: 1024px) { + width: calc(100% - 28px); + } + + @media (max-width: 640px) { + width: calc(100% - 24px); + } `; -const PageHeader = styled.div` - margin-bottom: 20px; +/* ─── hero ─── */ +const Hero = styled.div` + padding: 32px 0 24px; `; -const PageTitle = styled.h1` - font-family: var(--font-hanok); - font-size: clamp(22px, 3.5vw, 30px); - font-weight: 700; +const HeroTitle = styled.h1` + font-family: var(--font-hanok), serif; + font-size: clamp(26px, 4vw, 38px); + font-weight: 800; color: ${meok[900]}; - margin: 0 0 4px; - letter-spacing: -0.03em; + margin: 0 0 10px; + letter-spacing: -0.04em; + line-height: 1.2; [data-theme='dark'] & { - color: ${meok[100]}; + color: #ffffff; } `; -const PageSubtitle = styled.p` +const HeroSub = styled.p` font-size: ${fontSize.sm}; color: ${meok[500]}; - margin: 0 0 20px; + margin: 0; + line-height: 1.6; + word-break: keep-all; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.45); + } +`; + +/* ─── search ─── */ +const SearchRow = styled.div` + padding: 16px 0 14px; + position: relative; `; -const CountRow = styled.div` +const SearchIconWrap = styled.span` + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 12px; + color: ${meok[400]}; + pointer-events: none; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.30); + } `; -const MatchCount = styled.span` - font-size: ${fontSize.xs}; - color: ${meok[500]}; +const SearchInput = styled.input` + width: 100%; + box-sizing: border-box; + font-family: inherit; + font-size: ${fontSize.sm}; font-weight: 500; + color: ${meok[900]}; + background: ${meok[100]}; + border: none; + border-radius: 12px; + padding: 12px 16px 12px 42px; + outline: none; + letter-spacing: -0.01em; + box-shadow: ${ringShadow.light.input}; + transition: box-shadow 0.15s ease, background 0.15s ease; + + &::placeholder { + color: ${meok[400]}; + font-weight: 400; + } + + &:focus { + box-shadow: ${ringShadow.light.focusJuhong}; + background: #ffffff; + } + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.90); + background: rgba(255, 255, 255, 0.06); + box-shadow: ${ringShadow.dark.input}; + + &::placeholder { + color: rgba(255, 255, 255, 0.28); + } + + &:focus { + box-shadow: ${ringShadow.dark.focusJuhong}; + background: rgba(255, 255, 255, 0.09); + } + } `; -const WorksLink = styled(Link)` - display: inline-flex; +/* ─── section divider ─── */ +const SectionDivider = styled.div` + height: 1px; + background: linear-gradient(to right, ${meok[200]}, transparent); + margin: 40px 0 0; + + [data-theme='dark'] & { + background: linear-gradient(to right, rgba(255, 255, 255, 0.08), transparent); + } +`; + +const SectionGroupLabel = styled.h3` + font-size: ${fontSize.xs}; + font-weight: 700; + color: ${meok[400]}; + margin: 20px 0 0; + letter-spacing: 0.02em; + text-transform: uppercase; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.25); + } +`; + +/* ─── actions ─── */ +const ActionsBar = styled.div` + display: flex; align-items: center; - gap: 5px; + justify-content: flex-end; + padding: 8px 0 16px; +`; + +const WorksLink = styled(Link)` font-size: ${fontSize.xs}; font-weight: 600; - color: ${lightPalette.juhong[500]}; + color: ${meok[500]}; text-decoration: none; + letter-spacing: -0.01em; transition: color 0.15s ease; &:hover { - color: ${lightPalette.juhong[700]}; + color: ${meok[800]}; + } + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.38); + + &:hover { + color: rgba(255, 255, 255, 0.7); + } } `; +/* ─── unavailable ─── */ const UnavailableBox = styled.div` - padding: 48px 16px; + padding: 72px 24px; text-align: center; - border-radius: 16px; - background: ${meok[100]}; - color: ${meok[500]}; + border-radius: 20px; + background: rgba(0, 0, 0, 0.03); + margin-top: 8px; [data-theme='dark'] & { - background: ${meok[800]}; - color: ${meok[400]}; + background: rgba(255, 255, 255, 0.04); } `; 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); + } `; export interface KCultureDiscoveryProps { @@ -108,53 +245,98 @@ export interface KCultureDiscoveryProps { export default function KCultureDiscovery({ initialTopic = 'all', workId }: KCultureDiscoveryProps) { const router = useRouter(); const [activeTopic, setActiveTopic] = useState(initialTopic); + const [searchQuery, setSearchQuery] = useState(''); const { topics, isLoading: topicsLoading } = useDiscoveryTopics(); - const { - items, - matchingCount, - isLoading, - isError, - hasMore, - loadMore, - } = useDiscoveryPlaces({ + const { items, isLoading, isError, hasMore, loadMore } = useDiscoveryPlaces({ topic: activeTopic === 'all' ? undefined : activeTopic, workId, }); const handleTopicChange = (code: TopicCode | 'all') => { setActiveTopic(code); + setSearchQuery(''); }; - 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 q = searchQuery.trim(); + const filteredItems = q ? items.filter(p => p.name.includes(q)) : items; + + const isAllNoFilter = activeTopic === 'all' && !q; + + // 전체 탭 + 필터 없을 때만 섹션 분리 (featured[0] 제외하고 나머지 분류) + const grouped = useMemo(() => { + if (!isAllNoFilter || items.length < 2) return null; + const rest = items.slice(1); + + const byType = new Map(); + const byRegion = new Map(); + + rest.forEach((place) => { + const type = place.featuredRelation?.type; + if (type) { + if (!byType.has(type)) byType.set(type, []); + byType.get(type)!.push(place); + } + const rawRegion = place.region?.nameKo?.trim(); + const regionName = rawRegion?.replace(/(특별시|광역시|특별자치시|특별자치도|도)$/, '') + || (place.region?.code ? REGION_MAP[place.region.code] : null); + if (regionName) { + if (!byRegion.has(regionName)) byRegion.set(regionName, []); + byRegion.get(regionName)!.push(place); + } + }); + + return { byType, byRegion }; + }, [isAllNoFilter, items]); + + const typeSections = grouped + ? (Object.keys(TYPE_LABEL) as WorkType[]) + .filter(t => (grouped.byType.get(t)?.length ?? 0) >= 2) + .map(t => ({ label: TYPE_LABEL[t]!, items: grouped.byType.get(t)! })) + : []; + + const regionSections = grouped + ? [...grouped.byRegion.entries()] + .filter(([, v]) => v.length >= 2) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 5) + .map(([region, regionItems]) => ({ label: `${region}`, items: regionItems })) + : []; + + const hasSections = typeSections.length > 0 || regionSections.length > 0; + const serviceDown = isError && items.length === 0 && !isLoading; return ( - - - - - 전통 공간·문화유산부터 드라마·영화·MV 촬영지까지, 근거가 확인된 K-Culture 장소를 탐색하세요. - - + + 공간이 된 문화의 기록 + 드라마·영화·K-팝의 실제 공간을 탐색하세요. + {serviceDown ? ( 탐색 서비스 준비 중입니다 - - 콘텐츠 검수 및 서비스 준비가 완료되는 대로 공개될 예정입니다. - + 콘텐츠 검수 완료 후 공개 예정입니다. ) : ( <> + + + setSearchQuery(e.target.value)} + aria-label="장소 검색" + /> + + - - - {isLoading && items.length === 0 - ? '검색 중…' - : `장소 ${matchingCount.toLocaleString()}개`} - - - - - - + + 작품 목록 → + + + + + {/* 전체 탭 + 검색 없을 때: 히어로 + 섹션 */} + {isAllNoFilter && !isLoading && filteredItems.length > 0 && hasSections ? ( + <> + + + {typeSections.map(s => ( + + ))} + + {regionSections.length > 0 && ( + <> + + 지역별 + {regionSections.map(s => ( + + ))} + + )} + + ) : ( + /* 특정 탭 or 검색: 그리드 */ + + )} + + )} 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 deleted file mode 100644 index 9dd529fd..00000000 --- a/src/features/k-culture-discovery/infrastructure/fixtures.ts +++ /dev/null @@ -1,569 +0,0 @@ -import type { - DiscoveryTopic, - DiscoveryPlaceSummary, - DiscoveryPlaceDetail, - WorkSummary, - WorkDetail, - KContentRelation, - DiscoveryPlacesPage, - DiscoveryWorksPage, - KContentsPage, - DiscoveryPlacesParams, - DiscoveryWorksParams, - TopicCode, -} from '../domain/types'; - -export const FIXTURE_TOPICS: DiscoveryTopic[] = [ - { - code: 'TRADITIONAL_SPACE_HERITAGE', - labelKo: '전통 공간·문화유산', - labelEn: 'Traditional Spaces & Heritage', - placeCount: 128, - active: true, - }, - { - code: 'TRADITIONAL_EXPERIENCE', - labelKo: '전통 체험·공방', - labelEn: 'Traditional Experience', - placeCount: 56, - active: true, - }, - { - code: 'TRADITIONAL_FOOD_TEA', - labelKo: '전통 음식·다례', - labelEn: 'Traditional Food & Tea', - placeCount: 43, - active: true, - }, - { - code: 'K_DRAMA', - labelKo: 'K-드라마', - labelEn: 'K-Drama', - placeCount: 92, - active: true, - }, - { - code: 'K_MOVIE', - labelKo: 'K-영화', - labelEn: 'K-Movie', - placeCount: 64, - active: true, - }, - { - code: 'K_POP_MV', - labelKo: 'K-POP MV', - labelEn: 'K-POP Music Video', - placeCount: 38, - active: true, - }, -]; - -const FIXTURE_PLACES: DiscoveryPlaceSummary[] = [ - { - placeId: 'p-gyeongbok', - name: '경복궁', - region: { code: '11', nameKo: '서울' }, - placeRoles: [{ code: 'PALACE', labelKo: '궁궐' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/64/1939964_image2_1.jpg', - odiiLinked: true, - featuredRelation: { - relationId: 'r-gyeongbok-kingdom', - workId: 'w-kingdom', - workTitle: '킹덤', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 4, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-changdeok', - name: '창덕궁 후원', - region: { code: '11', nameKo: '서울' }, - placeRoles: [{ code: 'PALACE', labelKo: '궁궐' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/43/2408643_image2_1.jpg', - odiiLinked: true, - featuredRelation: { - relationId: 'r-changdeok-jewel', - workId: 'w-jewel-palace', - workTitle: '대장금', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 6, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-andong-hahoe', - name: '안동 하회마을', - region: { code: '47', nameKo: '경북' }, - placeRoles: [{ code: 'FOLK_VILLAGE', labelKo: '민속마을' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA', 'K_MOVIE'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/82/2567982_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-hahoe-mr-sunshine', - workId: 'w-mr-sunshine', - workTitle: '미스터 션샤인', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 3, - saveAvailable: true, - savedByMe: true, - }, - { - placeId: 'p-jeonju-hanok', - name: '전주 한옥마을', - region: { code: '45', nameKo: '전북' }, - placeRoles: [{ code: 'HANOK_VILLAGE', labelKo: '한옥마을' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'TRADITIONAL_EXPERIENCE', 'K_DRAMA'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/14/2633914_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-jeonju-rookie', - workId: 'w-rookie-historian', - workTitle: '신입사관 구해령', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 5, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-namhansanseong', - name: '남한산성', - region: { code: '41', nameKo: '경기' }, - placeRoles: [{ code: 'FORTRESS', labelKo: '산성·요새' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_MOVIE'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/00/2643200_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-namhan-siege', - workId: 'w-siege-namhansanseong', - workTitle: '남한산성', - type: 'MOVIE', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 2, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-gwanghanru', - name: '남원 광한루원', - region: { code: '45', nameKo: '전북' }, - placeRoles: [{ code: 'GARDEN', labelKo: '정원·누각' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/32/2612132_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-gwanghanru-seonjwa', - workId: 'w-seonjwa', - workTitle: '슈룹', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 2, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-manhyujeong', - name: '안동 만휴정', - region: { code: '47', nameKo: '경북' }, - placeRoles: [{ code: 'PAVILION', labelKo: '정자' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/80/3095780_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-manhyujeong-mr-sunshine', - workId: 'w-mr-sunshine', - workTitle: '미스터 션샤인', - type: 'DRAMA', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 1, - saveAvailable: true, - savedByMe: false, - }, - { - placeId: 'p-awon', - name: '완주 아원고택', - region: { code: '45', nameKo: '전북' }, - placeRoles: [{ code: 'HISTORIC_HOUSE', labelKo: '고택' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_POP_MV'], - placeImage: 'https://tong.visitkorea.or.kr/cms/resource/97/2641097_image2_1.jpg', - odiiLinked: false, - featuredRelation: { - relationId: 'r-awon-bts', - workId: 'w-bts-summer', - workTitle: 'BTS Summer Package 2019', - type: 'MUSIC_VIDEO', - relationType: 'FILMING_LOCATION', - }, - matchingWorkCount: 1, - saveAvailable: true, - savedByMe: false, - }, -]; - -const FIXTURE_PLACE_DETAILS: Record = { - 'p-gyeongbok': { - placeId: 'p-gyeongbok', - name: '경복궁', - region: { code: '11', nameKo: '서울' }, - address: '서울특별시 종로구 사직로 161', - coordinates: { latitude: 37.5796, longitude: 126.977 }, - description: - '조선 왕조 최초의 정궁으로 1395년 창건되었습니다. 근정전, 경회루, 향원정 등 아름다운 전각이 웅장한 북악산을 배경으로 펼쳐집니다. 유네스코 세계유산을 다수 보유한 조선 왕실 문화의 중심지입니다.', - sourceTaxonomy: { - provider: 'TOUR_API', - lclsSystm2: 'HS01', - lclsSystm3: null, - labelKo: '역사유적지', - }, - placeRoles: [{ code: 'PALACE', labelKo: '궁궐' }], - topics: ['TRADITIONAL_SPACE_HERITAGE', 'K_DRAMA'], - images: ['https://tong.visitkorea.or.kr/cms/resource/64/1939964_image2_1.jpg'], - odii: { linked: true, storyCount: 3 }, - kContents: { relationCount: 4, href: '/api/v1/places/p-gyeongbok/k-contents' }, - saveAvailable: true, - savedByMe: false, - }, - 'p-andong-hahoe': { - placeId: 'p-andong-hahoe', - name: '안동 하회마을', - region: { code: '47', nameKo: '경북' }, - address: '경상북도 안동시 풍천면 전서로 186', - coordinates: { latitude: 36.5366, longitude: 128.5215 }, - description: - '낙동강이 마을을 S자 모양으로 감싸 돌아 하회(河回)라 불리는 전통마을입니다. 풍산 류씨가 600여 년간 집성촌을 이루며 살아온 곳으로, 유네스코 세계문화유산으로 등재되었습니다.', - sourceTaxonomy: { - provider: 'TOUR_API', - lclsSystm2: 'CT01', - lclsSystm3: null, - labelKo: '민속마을', - }, - 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'], - odii: { linked: false, storyCount: 0 }, - kContents: { relationCount: 3, href: '/api/v1/places/p-andong-hahoe/k-contents' }, - saveAvailable: true, - savedByMe: true, - }, -}; - -const FIXTURE_WORKS: WorkSummary[] = [ - { - workId: 'w-kingdom', - canonicalTitle: '킹덤', - englishTitle: 'Kingdom', - type: 'DRAMA', - releaseYear: 2019, - artists: [], - workArtwork: null, - publicPlaceCount: 5, - workTags: ['사극', '조선', '좀비'], - }, - { - workId: 'w-mr-sunshine', - canonicalTitle: '미스터 션샤인', - englishTitle: 'Mr. Sunshine', - type: 'DRAMA', - releaseYear: 2018, - artists: [], - workArtwork: null, - publicPlaceCount: 8, - workTags: ['구한말', '사극', '로맨스'], - }, - { - workId: 'w-jewel-palace', - canonicalTitle: '대장금', - englishTitle: 'Jewel in the Palace', - type: 'DRAMA', - releaseYear: 2003, - artists: [], - workArtwork: null, - publicPlaceCount: 6, - workTags: ['조선', '궁중', '한식'], - }, - { - workId: 'w-siege-namhansanseong', - canonicalTitle: '남한산성', - englishTitle: 'The Fortress', - type: 'MOVIE', - releaseYear: 2017, - artists: [], - workArtwork: null, - publicPlaceCount: 3, - workTags: ['병자호란', '조선', '역사'], - }, - { - workId: 'w-bts-summer', - canonicalTitle: 'BTS Summer Package 2019', - englishTitle: 'BTS Summer Package 2019', - type: 'MUSIC_VIDEO', - releaseYear: 2019, - artists: [{ artistId: 'a-bts', name: 'BTS (방탄소년단)' }], - workArtwork: null, - publicPlaceCount: 2, - workTags: ['K-POP', '한옥'], - }, - { - workId: 'w-seonjwa', - canonicalTitle: '슈룹', - englishTitle: 'Under the Queen\'s Umbrella', - type: 'DRAMA', - releaseYear: 2022, - artists: [], - workArtwork: null, - publicPlaceCount: 4, - workTags: ['조선', '왕실', '정치'], - }, -]; - -const FIXTURE_WORK_DETAILS: Record = { - 'w-kingdom': { - workId: 'w-kingdom', - canonicalTitle: '킹덤', - englishTitle: 'Kingdom', - type: 'DRAMA', - releaseYear: 2019, - artists: [], - workArtwork: null, - publicPlaceCount: 5, - workTags: ['사극', '조선', '좀비'], - summaryPoints: [ - '조선 왕조를 배경으로 한 넷플릭스 오리지널 좀비 사극 시리즈입니다.', - '세자 이창이 왕의 죽음을 둘러싼 음모와 전염병의 비밀을 파헤치는 이야기입니다.', - '경복궁, 수원화성 등 실제 조선 시대 유적지를 주요 촬영지로 활용했습니다.', - ], - officialPageUrl: null, - metadataSources: [], - placesHref: '/api/v1/discovery/places?workId=w-kingdom', - }, - 'w-mr-sunshine': { - workId: 'w-mr-sunshine', - canonicalTitle: '미스터 션샤인', - englishTitle: 'Mr. Sunshine', - type: 'DRAMA', - releaseYear: 2018, - artists: [], - workArtwork: null, - publicPlaceCount: 8, - workTags: ['구한말', '사극', '로맨스'], - summaryPoints: [ - '구한말 격변기를 배경으로 한 대하 로맨스 사극 드라마입니다.', - '미국 군인이 된 조선인 유진 초이가 의병 활동을 하는 고애신을 만나는 이야기입니다.', - '안동 만휴정, 하회마을 등 경북 전통 한옥 명소들이 촬영지로 등장합니다.', - ], - officialPageUrl: null, - metadataSources: [], - placesHref: '/api/v1/discovery/places?workId=w-mr-sunshine', - }, -}; - -const FIXTURE_K_CONTENTS: Record = { - 'p-gyeongbok': [ - { - relationId: 'r-gyeongbok-kingdom', - relationType: 'FILMING_LOCATION', - work: { workId: 'w-kingdom', canonicalTitle: '킹덤', type: 'DRAMA', releaseYear: 2019 }, - contextSummary: '경복궁 근정전이 세자 이창의 즉위식 장면 등 주요 왕실 의례 씬에 활용됐습니다.', - relationTags: ['왕실', '조선', '사극'], - evidence: [ - { - sourceTitle: '넷플릭스 킹덤 공식 촬영지 안내', - sourceUrl: 'https://example.org/kingdom-locations', - sourceType: 'OFFICIAL_TOURISM', - publisherName: '한국관광공사', - checkedAt: '2026-10-08T09:00:00Z', - }, - ], - evidenceCount: 1, - verifiedAt: '2026-10-08T09:00:00Z', - }, - { - relationId: 'r-gyeongbok-seonjwa', - relationType: 'FILMING_LOCATION', - work: { workId: 'w-seonjwa', canonicalTitle: '슈룹', type: 'DRAMA', releaseYear: 2022 }, - contextSummary: '경복궁 경회루와 향원정이 왕실 연회 및 왕비의 산책 장면에 등장합니다.', - relationTags: ['왕실', '조선', '사극'], - evidence: [ - { - sourceTitle: '슈룹 공식 촬영지 발표', - sourceUrl: 'https://example.org/seonjwa-locations', - sourceType: 'OFFICIAL_PRESS', - publisherName: 'tvN 공식 보도', - checkedAt: '2026-10-08T09:00:00Z', - }, - ], - evidenceCount: 1, - verifiedAt: '2026-10-08T09:00:00Z', - }, - ], - 'p-andong-hahoe': [ - { - relationId: 'r-hahoe-mr-sunshine', - relationType: 'FILMING_LOCATION', - work: { workId: 'w-mr-sunshine', canonicalTitle: '미스터 션샤인', type: 'DRAMA', releaseYear: 2018 }, - contextSummary: '고애신의 고택으로 등장하는 안동 하회마을 양진당이 주요 배경으로 활용됐습니다.', - relationTags: ['구한말', '한옥', '사극'], - evidence: [ - { - sourceTitle: '미스터 션샤인 제작 발표 촬영지 공개', - sourceUrl: 'https://example.org/mr-sunshine-locations', - sourceType: 'OFFICIAL_PRESS', - publisherName: 'tvN 보도자료', - checkedAt: '2026-10-08T09:00:00Z', - }, - { - sourceTitle: '안동시 문화유산 드라마 촬영지 안내', - sourceUrl: 'https://example.org/andong-drama', - sourceType: 'OFFICIAL_TOURISM', - publisherName: '안동시 관광', - checkedAt: '2026-10-08T09:00:00Z', - }, - ], - evidenceCount: 2, - verifiedAt: '2026-10-08T09:00:00Z', - }, - ], -}; - -export function getFixtureTopics(): DiscoveryTopic[] { - return FIXTURE_TOPICS; -} - -export function getFixturePlacesPage(params?: DiscoveryPlacesParams): DiscoveryPlacesPage { - let items = [...FIXTURE_PLACES]; - - if (params?.topic) { - items = items.filter((p) => p.topics.includes(params.topic!)); - } - if (params?.regionCode) { - items = items.filter((p) => p.region.code === params.regionCode); - } - - const limit = params?.limit ?? 20; - const sliced = items.slice(0, limit); - - return { - catalogRevision: 'fixture-2026-10-11', - countsAsOf: '2026-10-11T09:00:00Z', - matchingPlaceCount: items.length, - facets: { - topic: FIXTURE_TOPICS.map((t) => ({ code: t.code, labelKo: t.labelKo, count: t.placeCount })), - regionCode: [ - { code: '11', labelKo: '서울', count: 2 }, - { code: '45', labelKo: '전북', count: 3 }, - { code: '47', labelKo: '경북', count: 2 }, - { code: '41', labelKo: '경기', count: 1 }, - ], - placeRole: [ - { code: 'PALACE', labelKo: '궁궐', count: 2 }, - { code: 'FOLK_VILLAGE', labelKo: '민속마을', count: 1 }, - { code: 'HANOK_VILLAGE', labelKo: '한옥마을', count: 1 }, - { code: 'FORTRESS', labelKo: '산성·요새', count: 1 }, - { code: 'GARDEN', labelKo: '정원·누각', count: 1 }, - { code: 'PAVILION', labelKo: '정자', count: 1 }, - { code: 'HISTORIC_HOUSE', labelKo: '고택', count: 1 }, - ], - type: [ - { code: 'DRAMA', count: 6 }, - { code: 'MOVIE', count: 2 }, - { code: 'MUSIC_VIDEO', count: 1 }, - ], - workTagCode: [], - relationTagCode: [], - }, - items: sliced, - hasMore: items.length > limit, - nextCursor: items.length > limit ? 'fixture-cursor-1' : null, - }; -} - -export function getFixturePlaceDetail(placeId: string): DiscoveryPlaceDetail | null { - if (FIXTURE_PLACE_DETAILS[placeId]) return FIXTURE_PLACE_DETAILS[placeId]; - const summary = FIXTURE_PLACES.find((p) => p.placeId === placeId); - if (!summary) return null; - return { - placeId: summary.placeId, - name: summary.name, - region: summary.region, - address: `${summary.region.nameKo} 주소 정보 준비 중`, - coordinates: { latitude: 36.5, longitude: 127.5 }, - description: '상세 설명 준비 중입니다.', - sourceTaxonomy: { provider: 'TOUR_API', lclsSystm2: null, lclsSystm3: null, labelKo: '기타' }, - placeRoles: summary.placeRoles, - topics: summary.topics, - images: summary.placeImage ? [summary.placeImage] : [], - odii: { linked: summary.odiiLinked, storyCount: 0 }, - kContents: { - relationCount: summary.matchingWorkCount, - href: `/api/v1/places/${placeId}/k-contents`, - }, - saveAvailable: summary.saveAvailable, - savedByMe: summary.savedByMe, - }; -} - -export function getFixtureWorksPage(params?: DiscoveryWorksParams): DiscoveryWorksPage { - let items = [...FIXTURE_WORKS]; - if (params?.type) items = items.filter((w) => w.type === params.type); - if (params?.q) { - const q = params.q.toLowerCase(); - items = items.filter((w) => w.canonicalTitle.toLowerCase().includes(q)); - } - const limit = params?.limit ?? 20; - const sliced = items.slice(0, limit); - return { - catalogRevision: 'fixture-2026-10-11', - countsAsOf: '2026-10-11T09:00:00Z', - matchingWorkCount: items.length, - items: sliced, - hasMore: items.length > limit, - nextCursor: items.length > limit ? 'fixture-cursor-1' : null, - }; -} - -export function getFixtureWorkDetail(workId: string): WorkDetail | null { - if (FIXTURE_WORK_DETAILS[workId]) return FIXTURE_WORK_DETAILS[workId]; - const summary = FIXTURE_WORKS.find((w) => w.workId === workId); - if (!summary) return null; - return { - ...summary, - summaryPoints: [], - officialPageUrl: null, - metadataSources: [], - placesHref: `/api/v1/discovery/places?workId=${workId}`, - }; -} - -export function getFixtureKContentsPage(placeId: string): KContentsPage { - const items = FIXTURE_K_CONTENTS[placeId] ?? []; - return { - catalogRevision: 'fixture-2026-10-11', - countsAsOf: '2026-10-11T09:00:00Z', - matchingRelationCount: items.length, - items, - hasMore: false, - nextCursor: null, - }; -} - -export const ALL_TOPIC_CODES: TopicCode[] = [ - 'TRADITIONAL_SPACE_HERITAGE', - 'TRADITIONAL_EXPERIENCE', - 'TRADITIONAL_FOOD_TEA', - 'K_DRAMA', - 'K_MOVIE', - 'K_POP_MV', -]; diff --git a/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx b/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx index b0b786cb..813f7a47 100644 --- a/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx +++ b/src/features/k-culture-discovery/presentation/components/DiscoveryPlaceCard.tsx @@ -5,207 +5,227 @@ 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 { meok, lightPalette, ringShadow, fontSize } from '@/design-system/tokens'; +import type { IconSvgElement } from '@hugeicons/react'; +import { Layers01Icon } from '@hugeicons/core-free-icons'; +import { meok, ringShadow } from '@/design-system/tokens'; import WorkTypeBadge from './WorkTypeBadge'; +import { useTourApiImage } from '../hooks/useTourApiImage'; import type { DiscoveryPlaceSummary } from '../../domain/types'; -const shimmerAnim = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +const REGION_CODE_MAP: Record = { + '1': '서울', '2': '인천', '3': '대전', '4': '대구', '5': '광주', + '6': '부산', '7': '울산', '8': '세종', '31': '경기', '32': '강원', + '33': '충북', '34': '충남', '35': '전북', '36': '전남', '37': '경북', + '38': '경남', '39': '제주', '11': '서울', '26': '부산', '27': '대구', + '28': '인천', '29': '광주', '30': '대전', '41': '경기', '42': '강원', + '43': '충북', '44': '충남', '45': '전북', '46': '전남', '47': '경북', + '48': '경남', '50': '제주', +}; + +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: 16px; overflow: hidden; - background: #ffffff; border: none; padding: 0; cursor: pointer; text-align: left; width: 100%; - box-shadow: ${ringShadow.light.cardElevated}; - transition: box-shadow 0.22s ease; + display: block; + transition: box-shadow 0.28s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.28s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: ${ringShadow.light.card}; + will-change: transform; [data-theme='dark'] & { - background: ${meok[800]}; - box-shadow: ${ringShadow.dark.cardElevated}; + box-shadow: ${ringShadow.dark.card}; } &:hover { - box-shadow: ${ringShadow.light.cardLift}; + transform: translateY(-3px); + box-shadow: 0 12px 36px rgba(0,0,0,0.20), 0 3px 10px rgba(0,0,0,0.10); [data-theme='dark'] & { - box-shadow: ${ringShadow.dark.cardLift}; + box-shadow: 0 12px 36px rgba(0,0,0,0.55), 0 3px 10px rgba(0,0,0,0.35); } } - &:hover img { - transform: scale(1.05); + &:hover .place-img { + transform: scale(1.06); + } + + &:active { + transform: translateY(0); + filter: brightness(0.90); } `; const ImageWrap = styled.div` position: relative; - aspect-ratio: 4 / 3; + aspect-ratio: 3 / 4; overflow: hidden; + background: #ccc8c0; + + @media (min-width: 480px) { + aspect-ratio: 4 / 3; + } + + [data-theme='dark'] & { + background: #0f1420; + } `; const PlaceImage = styled.img` width: 100%; height: 100%; object-fit: cover; - transition: transform 0.36s cubic-bezier(0.16, 1, 0.3, 1); + transition: transform 0.55s 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%); -`; - -const FallbackImage = styled.div` +const FallbackWrap = styled.div<{ $type?: string }>` width: 100%; height: 100%; - background: ${meok[200]}; - display: flex; - align-items: center; - justify-content: center; - color: ${meok[400]}; + background: ${({ $type }) => { + switch ($type) { + case 'DRAMA': case 'K_DRAMA': return 'linear-gradient(160deg, #d6d0e8 0%, #c8c0dc 100%)'; + case 'MOVIE': case 'K_MOVIE': return 'linear-gradient(160deg, #c8ccd8 0%, #bcc0cc 100%)'; + case 'MUSIC_VIDEO': case 'K_POP_MV': + case 'ARTIST': case 'K_ARTIST': return 'linear-gradient(160deg, #d8d0e8 0%, #ccbede 100%)'; + case 'VARIETY': return 'linear-gradient(160deg, #dcd8cc 0%, #cec8bc 100%)'; + default: return 'linear-gradient(160deg, #d4cfc6 0%, #c8c2b8 100%)'; + } + }}; [data-theme='dark'] & { - background: ${meok[700]}; - color: ${meok[500]}; + background: ${({ $type }) => { + switch ($type) { + case 'DRAMA': case 'K_DRAMA': return 'linear-gradient(160deg, #1e1830 0%, #16122a 100%)'; + case 'MOVIE': case 'K_MOVIE': return 'linear-gradient(160deg, #181c2e 0%, #12162a 100%)'; + case 'MUSIC_VIDEO': case 'K_POP_MV': + case 'ARTIST': case 'K_ARTIST': return 'linear-gradient(160deg, #1c1530 0%, #140e28 100%)'; + case 'VARIETY': return 'linear-gradient(160deg, #201e18 0%, #181612 100%)'; + default: return 'linear-gradient(160deg, #1a1e2e 0%, #111520 100%)'; + } + }}; } `; -const BadgeRow = styled.div` +const BottomScrim = styled.div` + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0,0,0,0.06) 0%, + transparent 28%, + transparent 44%, + rgba(0,0,0,0.52) 70%, + rgba(0,0,0,0.86) 100% + ); + pointer-events: none; + z-index: 1; +`; + +const TopRow = styled.div` position: absolute; top: 10px; left: 10px; right: 10px; display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; + z-index: 2; + gap: 4px; `; -const WorkCountBadge = styled.span` +const MultiPill = styled.span` display: inline-flex; align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 9999px; - background: ${lightPalette.juhong[500]}; - color: #ffffff; - font-size: 11px; - font-weight: 600; + gap: 3px; + padding: 3px 7px; + border-radius: 6px; + background: rgba(8,12,22,0.72); + backdrop-filter: blur(8px); + color: rgba(255,255,255,0.88); + font-size: 10px; + font-weight: 700; + letter-spacing: -0.01em; + margin-left: auto; `; -const PlaceRolePill = styled.span` +const BottomInfo = styled.div` position: absolute; - bottom: 10px; - left: 10px; - padding: 3px 10px; - border-radius: 9999px; - background: rgba(255,255,255,0.18); - backdrop-filter: blur(6px); - color: rgba(255,255,255,0.92); - font-size: 11px; - font-weight: 500; -`; - -const Body = styled.div` - padding: 12px 14px 14px; + bottom: 0; + left: 0; + right: 0; + padding: 10px 12px 13px; + z-index: 2; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; + min-width: 0; `; -const PlaceName = styled.h3` - font-family: var(--font-hanok); - font-size: ${fontSize.base}; - font-weight: 700; - color: ${meok[900]}; +const PlaceNameOverlay = styled.h3` + font-family: var(--font-hanok), serif; + font-size: 15px; + font-weight: 800; + color: #ffffff; margin: 0; - line-height: 1.3; - letter-spacing: -0.02em; - - [data-theme='dark'] & { - color: ${meok[100]}; - } + line-height: 1.25; + letter-spacing: -0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 8px rgba(0,0,0,0.28); `; -const RegionRow = styled.div` +const MetaRow = styled.div` display: flex; align-items: center; - gap: 3px; - color: ${meok[500]}; - font-size: ${fontSize.xs}; + gap: 4px; + font-size: 10.5px; + color: rgba(255,255,255,0.62); font-weight: 500; -`; - -const WorkTitle = styled.p` - font-size: ${fontSize.xs}; - color: ${lightPalette.juhong[600]}; - margin: 0; + letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-weight: 500; + line-height: 1.3; +`; - [data-theme='dark'] & { - color: ${lightPalette.juhong[400]}; - } +const MetaSep = styled.span` + flex-shrink: 0; + opacity: 0.55; `; +/* ─── skeleton ─── */ export const SkeletonCard = styled.div` - border-radius: 18px; + border-radius: 16px; overflow: hidden; - background: ${meok[200]}; - box-shadow: ${ringShadow.light.cardElevated}; + background: ${meok[100]}; [data-theme='dark'] & { - background: #252422; - box-shadow: ${ringShadow.dark.cardElevated}; + background: #161c28; } `; -const SkeletonImageWrap = styled.div` - aspect-ratio: 4 / 3; - background: linear-gradient(90deg, #e4e4e2 25%, #dcdcd9 50%, #e4e4e2 75%); +const SkImgWrap = styled.div` + aspect-ratio: 3 / 4; + background: linear-gradient(90deg, ${meok[100]} 25%, #ece9e3 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-size: 200% 100%; + @media (min-width: 480px) { + aspect-ratio: 4 / 3; } -`; - -const SkeletonBody = styled.div` - padding: 12px 14px 14px; - display: flex; - flex-direction: column; - gap: 6px; -`; - -const SkeletonLine = 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%); - background-size: 200% 100%; - animation: ${shimmerAnim} 1.6s 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.07) 50%, rgba(255,255,255,0.04) 75%); background-size: 200% 100%; } `; @@ -213,12 +233,7 @@ const SkeletonLine = styled.div<{ $w?: string; $h?: string }>` export function DiscoveryPlaceCardSkeleton() { return ( - - - - - - + ); } @@ -232,52 +247,57 @@ export interface DiscoveryPlaceCardProps { export default function DiscoveryPlaceCard({ place, index = 0, onClick }: DiscoveryPlaceCardProps) { const primaryRole = place.placeRoles[0]; const relation = place.featuredRelation; + const resolvedImage = useTourApiImage(place.placeId, place.placeImage); + const workType = relation?.type ?? place.topics?.[0] ?? undefined; + + const rawRegion = place.region?.nameKo?.trim(); + const regionName = rawRegion || (place.region?.code ? REGION_CODE_MAP[place.region.code] : null); + + const metaSecond = relation?.workTitle ?? primaryRole?.labelKo ?? null; return ( onClick?.(place)} > - {place.placeImage ? ( - + {resolvedImage ? ( + ) : ( - - + )} - - - {relation && ( - - )} + + + + {relation && } {place.matchingWorkCount > 1 && ( - - + + )} - - - {primaryRole && ( - {primaryRole.labelKo} - )} + + + + {place.name} + {(regionName || metaSecond) && ( + + {regionName && {regionName}} + {regionName && metaSecond && ·} + {metaSecond && ( + + {metaSecond} + + )} + + )} + - - - {place.name} - - - {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..b646713a 100644 --- a/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx +++ b/src/features/k-culture-discovery/presentation/components/DiscoveryPlacesSection.tsx @@ -1,88 +1,189 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect, useRef } 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 { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'; +import { meok, ringShadow, fontSize } from '@/design-system/tokens'; import DiscoveryPlaceCard, { DiscoveryPlaceCardSkeleton } from './DiscoveryPlaceCard'; import type { DiscoveryPlaceSummary } from '../../domain/types'; +const ITEMS_PER_PAGE = 6; + const Grid = styled.div` display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - @media (min-width: 640px) { + @media (min-width: 768px) { grid-template-columns: repeat(3, 1fr); + gap: 16px; } - @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); } `; -const LoadMoreBtn = styled.button` +/* ─── pagination controls ─── */ +const PaginationContainer = styled.nav` display: flex; align-items: center; justify-content: center; gap: 6px; - width: 100%; - margin-top: 4px; - padding: 14px; - border-radius: 12px; + margin-top: 36px; + padding: 8px 0 24px; + user-select: none; +`; + +const PageBtn = styled.button<{ $active?: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 10px; + border-radius: 10px; border: none; - background: ${meok[100]}; - color: ${meok[600]}; - font-size: ${fontSize.sm}; - font-weight: 600; - cursor: pointer; font-family: inherit; - transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; + font-size: ${fontSize.xs}; + font-weight: ${({ $active }) => ($active ? 700 : 500)}; + cursor: pointer; + transition: all 0.16s ease; + + background: ${({ $active }) => ($active ? meok[900] : '#ffffff')}; + color: ${({ $active }) => ($active ? '#ffffff' : meok[700])}; + box-shadow: ${({ $active }) => ($active ? ringShadow.light.button : ringShadow.light.card)}; + + &:hover:not(:disabled) { + background: ${({ $active }) => ($active ? meok[800] : meok[100])}; + color: ${({ $active }) => ($active ? '#ffffff' : meok[900])}; + box-shadow: ${ringShadow.light.buttonHover}; + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + box-shadow: none; + transform: none; + } + + [data-theme='dark'] & { + background: ${({ $active }) => ($active ? '#ffffff' : '#161c28')}; + color: ${({ $active }) => ($active ? meok[900] : 'rgba(255, 255, 255, 0.75)')}; + box-shadow: ${({ $active }) => ($active ? ringShadow.dark.button : ringShadow.dark.card)}; + + &:hover:not(:disabled) { + background: ${({ $active }) => ($active ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.1)')}; + color: ${({ $active }) => ($active ? meok[900] : '#ffffff')}; + box-shadow: ${ringShadow.dark.buttonHover}; + } + } +`; + +const ArrowBtn = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + border: none; + cursor: pointer; + background: #ffffff; + color: ${meok[700]}; + box-shadow: ${ringShadow.light.card}; + transition: all 0.16s ease; - &:hover { - background: ${meok[200]}; - color: ${lightPalette.juhong[500]}; - box-shadow: ${ringShadow.light.chipHover}; + &:hover:not(:disabled) { + background: ${meok[100]}; + color: ${meok[900]}; + box-shadow: ${ringShadow.light.buttonHover}; + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + box-shadow: none; + transform: none; } [data-theme='dark'] & { - background: ${meok[800]}; - color: ${meok[300]}; + background: #161c28; + color: rgba(255, 255, 255, 0.75); + box-shadow: ${ringShadow.dark.card}; - &:hover { - background: ${meok[700]}; + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + box-shadow: ${ringShadow.dark.buttonHover}; } } `; +const Dots = styled.span` + color: ${meok[400]}; + padding: 0 4px; + font-size: ${fontSize.xs}; + + [data-theme='dark'] & { + color: rgba(255, 255, 255, 0.35); + } +`; + +function getPageList(current: number, total: number): (number | 'dots')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const pages: (number | 'dots')[] = [1]; + if (current > 3) pages.push('dots'); + + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) { + if (!pages.includes(i)) pages.push(i); + } + + if (current < total - 2) pages.push('dots'); + pages.push(total); + return pages; +} + export interface DiscoveryPlacesSectionProps { items: DiscoveryPlaceSummary[]; isLoading: boolean; isError: boolean; hasMore: boolean; + filterKey?: string; onLoadMore: () => void; onPlaceClick: (place: DiscoveryPlaceSummary) => void; } @@ -92,9 +193,18 @@ export default function DiscoveryPlacesSection({ isLoading, isError, hasMore, + filterKey, onLoadMore, onPlaceClick, }: DiscoveryPlacesSectionProps) { + const sectionRef = useRef(null); + const [currentPage, setCurrentPage] = useState(1); + + // Reset to first page when filtering conditions change + useEffect(() => { + setCurrentPage(1); + }, [filterKey]); + if (isLoading && items.length === 0) { return ( @@ -108,9 +218,7 @@ export default function DiscoveryPlacesSection({ if (isError && items.length === 0) { return ( - - 탐색 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요. - + 탐색 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요. ); } @@ -118,32 +226,83 @@ export default function DiscoveryPlacesSection({ if (!isLoading && items.length === 0) { return ( - 이 조건에 맞는 장소가 없습니다. 다른 주제나 필터를 선택해 보세요. + 이 조건에 맞는 장소가 없습니다. ); } + const totalPages = Math.max(1, Math.ceil(items.length / ITEMS_PER_PAGE)); + const pageToRender = Math.min(currentPage, totalPages); + const currentItems = items.slice((pageToRender - 1) * ITEMS_PER_PAGE, pageToRender * ITEMS_PER_PAGE); + + const handlePageChange = (page: number) => { + if (page < 1 || page > totalPages || page === currentPage) return; + setCurrentPage(page); + + // If nearing the end of fetched items and more are available, pre-fetch next batch + if (hasMore && (page + 1) * ITEMS_PER_PAGE >= items.length) { + onLoadMore(); + } + + if (sectionRef.current) { + const yOffset = -90; + const y = sectionRef.current.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' }); + } + }; + return ( - <> +
- {items.map((place, i) => ( + {currentItems.map((place, i) => ( ))} - {isLoading && - [...Array(3)].map((_, i) => )} + {isLoading && [...Array(3)].map((_, i) => )} - {hasMore && !isLoading && ( - - + {totalPages > 1 && ( + + handlePageChange(pageToRender - 1)} + aria-label="이전 페이지" + > + + + {getPageList(pageToRender, totalPages).map((p, idx) => + p === 'dots' ? ( + … + ) : ( + handlePageChange(p)} + aria-current={p === pageToRender ? 'page' : undefined} + aria-label={`${p}페이지`} + > + {p} + + ) + )} + + handlePageChange(pageToRender + 1)} + aria-label="다음 페이지" + > + + )} - +
); } diff --git a/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx b/src/features/k-culture-discovery/presentation/components/DiscoveryWorkCard.tsx index 124f8c3e..4b33bd47 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, Camera01Icon, Mic01Icon } 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,17 @@ export default function DiscoveryWorkCard({ work, index = 0, onClick }: Discover {work.workArtwork ? ( ) : ( -