From a73304d60edafd4fc512d511f905cd768134b420 Mon Sep 17 00:00:00 2001 From: YangSeungHyun <96910404+SHcommit@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:50:30 +0900 Subject: [PATCH] chore: release v0.1.23 (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map-warmth): 지도 온기모드 말풍선 다중 줄바꿈 지원, 오버레이 깜빡임 제거 및 피드 스켈레톤 적용 (#402) * feat(map-warmth): 지도 온기모드 말풍선 다중 줄바꿈 지원, 오버레이 깜빡임 제거 및 피드 스켈레톤 적용 * test: typographyRole 적용에 따른 단위 테스트 폰트 크기 기댓값 동기화 * refactor(map-warmth): CodeQL 피드백 반영 미사용 import 정리 * chore(release): bump version to 0.1.23 --- package.json | 2 +- .../components/VillageCard.test.tsx | 5 +- .../components/SorimaruArchiveBrowse.test.tsx | 3 +- .../components/StoryCarousel.test.tsx | 3 +- .../presentation/useVisitReviewFeed.ts | 8 +- .../core-ui/map-warmth/WarmthCardSkeleton.tsx | 217 ++++++++++ src/private/core-ui/map-warmth/WarmthFeed.tsx | 36 +- .../core-ui/map-warmth/WarmthNotesLayer.tsx | 383 ++++++++++-------- 8 files changed, 466 insertions(+), 191 deletions(-) create mode 100644 src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx diff --git a/package.json b/package.json index f6a6dc64..5580fa81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "onmaru-frontend", - "version": "0.1.22", + "version": "0.1.23", "private": true, "scripts": { "dev": "next dev", diff --git a/src/features/hanok-archive/components/VillageCard.test.tsx b/src/features/hanok-archive/components/VillageCard.test.tsx index ecbad038..ba146b60 100644 --- a/src/features/hanok-archive/components/VillageCard.test.tsx +++ b/src/features/hanok-archive/components/VillageCard.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Village } from '@/features/hanok-archive/types'; +import { typographyRole } from '@/design-system/tokens'; import VillageCard from './VillageCard'; const village: Village = { @@ -33,8 +34,8 @@ describe('VillageCard', () => { const tagArea = screen.getByLabelText('한옥 태그'); expect(tagArea.textContent).toContain('한옥'); expect(tagArea.textContent).toContain('소리마루 도슨트'); - expect(getComputedStyle(tagArea.children[0]).fontSize).toBe('0.75rem'); - expect(getComputedStyle(tagArea.children[1]).fontSize).toBe('0.75rem'); + expect(getComputedStyle(tagArea.children[0]).fontSize).toBe(typographyRole.label.fontSize); + expect(getComputedStyle(tagArea.children[1]).fontSize).toBe(typographyRole.label.fontSize); }); it('keeps the complete title available while limiting it to two visual lines', () => { diff --git a/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx b/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx index 425bfb81..1b289140 100644 --- a/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx +++ b/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SorimaruStorySummary } from '@/features/sorimaru-audio/domain/sorimaruStory'; +import { typographyRole } from '@/design-system/tokens'; import { SorimaruArchiveBrowse } from './SorimaruArchiveBrowse'; vi.mock('@/features/sorimaru-audio/hooks/useSorimaruImage', () => ({ @@ -68,7 +69,7 @@ describe('SorimaruArchiveBrowse typography', () => { const title = screen.getByRole('heading', { name: LONG_TITLE }); expect(getComputedStyle(title).webkitLineClamp).toBe('2'); - expect(getComputedStyle(title).fontSize).toBe('1rem'); + expect(getComputedStyle(title).fontSize).toBe(typographyRole.body.fontSize); const article = title.closest('article'); expect(article).not.toBeNull(); diff --git a/src/features/sorimaru-audio/components/StoryCarousel.test.tsx b/src/features/sorimaru-audio/components/StoryCarousel.test.tsx index 739f806c..63b17608 100644 --- a/src/features/sorimaru-audio/components/StoryCarousel.test.tsx +++ b/src/features/sorimaru-audio/components/StoryCarousel.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import type { SorimaruStorySummary } from '@/features/sorimaru-audio/domain/sorimaruStory'; +import { typographyRole } from '@/design-system/tokens'; import { StoryCarousel, StoryCarouselSkeleton } from './StoryCarousel'; vi.mock('@gsap/react', () => ({ useGSAP: () => undefined })); @@ -62,7 +63,7 @@ describe('StoryCarousel typography', () => { const title = screen.getByRole('heading', { name: LONG_TITLE }); expect(getComputedStyle(title).webkitLineClamp).toBe('2'); - expect(getComputedStyle(title).fontSize).toBe('1rem'); + expect(getComputedStyle(title).fontSize).toBe(typographyRole.body.fontSize); const card = title.closest('[data-story-card]'); expect(card).not.toBeNull(); diff --git a/src/features/visit-review/presentation/useVisitReviewFeed.ts b/src/features/visit-review/presentation/useVisitReviewFeed.ts index a85504b5..10e8f3e1 100644 --- a/src/features/visit-review/presentation/useVisitReviewFeed.ts +++ b/src/features/visit-review/presentation/useVisitReviewFeed.ts @@ -9,6 +9,7 @@ export function useVisitReviewFeed(regionCode?: string) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [reloadNonce, setReloadNonce] = useState(0); useEffect(() => { let cancelled = false; @@ -29,7 +30,10 @@ export function useVisitReviewFeed(regionCode?: string) { return () => { cancelled = true; }; - }, [regionCode]); + }, [regionCode, reloadNonce]); - return { data, loading, error }; + const reload = () => setReloadNonce((n) => n + 1); + + return { data, loading, error, reload }; } + diff --git a/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx b/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx new file mode 100644 index 00000000..4a5731dc --- /dev/null +++ b/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React from 'react'; +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { surface } from '@/design-system/tokens'; + +const shimmer = keyframes` + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +`; + +const SkeletonBase = styled.div` + background: linear-gradient(100deg, #e5e5e3 20%, #f5f5f4 42%, #e5e5e3 64%); + background-size: 200% 100%; + animation: ${shimmer} 1.8s ease-in-out infinite; + + [data-theme='dark'] & { + background: linear-gradient(100deg, #30302e 20%, #454542 42%, #30302e 64%); + background-size: 200% 100%; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +`; + +const CardSkeletonWrapper = styled.article` + padding: 16px; + margin: 4px 0 10px; + border-radius: 20px; + background: ${surface.light.surface}; + border: none; + box-sizing: border-box; + + [data-theme='dark'] & { + background: ${surface.dark.surface}; + box-shadow: var(--shadow-card-inset); + } +`; + +const PlaceHeaderSkeleton = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; + +const CategoryIconSkeleton = styled(SkeletonBase)` + width: 38px; + height: 38px; + border-radius: 12px; + flex-shrink: 0; +`; + +const PlaceInfoSkeleton = styled.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const TitleRowSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const PlaceTitleSkeleton = styled(SkeletonBase)` + height: 18px; + width: 110px; + border-radius: 4px; +`; + +const BadgeSkeleton = styled(SkeletonBase)` + height: 20px; + width: 72px; + border-radius: 9999px; +`; + +const MetaSkeleton = styled(SkeletonBase)` + height: 14px; + width: 140px; + border-radius: 4px; +`; + +const MoodSkeleton = styled(SkeletonBase)` + margin: 12px 0 12px; + height: 48px; + border-radius: 16px; + width: 100%; +`; + +const ReviewBodySkeleton = styled.div` + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TagsRowSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +`; + +const TagChipSkeleton = styled(SkeletonBase)<{ $width: number }>` + height: 26px; + width: ${({ $width }) => $width}px; + border-radius: 9999px; +`; + +const TextLineSkeleton = styled(SkeletonBase)<{ $width: string }>` + height: 15px; + width: ${({ $width }) => $width}; + border-radius: 4px; +`; + +const FooterSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 14px; + padding-top: 4px; +`; + +const AuthorSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const AvatarSkeleton = styled(SkeletonBase)` + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; +`; + +const DateSkeleton = styled(SkeletonBase)` + height: 14px; + width: 90px; + border-radius: 4px; +`; + +const HelpfulBtnSkeleton = styled(SkeletonBase)` + height: 30px; + width: 80px; + border-radius: 9999px; +`; + +const RelatedPlaceSkeleton = styled(SkeletonBase)` + margin-top: 12px; + height: 56px; + border-radius: 14px; + width: 100%; +`; + +export function WarmthCardSkeleton() { + return ( + + ); +} + +export function WarmthFeedSkeletonList({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/src/private/core-ui/map-warmth/WarmthFeed.tsx b/src/private/core-ui/map-warmth/WarmthFeed.tsx index 9edb403a..ad73039e 100644 --- a/src/private/core-ui/map-warmth/WarmthFeed.tsx +++ b/src/private/core-ui/map-warmth/WarmthFeed.tsx @@ -12,6 +12,7 @@ import { useVisitReviewFeed } from '@/features/visit-review/presentation/useVisi import { useVisitReviewRegions } from '@/features/visit-review/presentation/useVisitReviewRegions'; import DateScrubber from './DateScrubber'; import WarmthCard from './WarmthCard'; +import { WarmthFeedSkeletonList } from './WarmthCardSkeleton'; import { OniSearchEmpty } from '@/shared/components/OniSearchEmpty'; import { FeedContainer, @@ -96,9 +97,12 @@ export default function WarmthFeed() { const period = useMapStore((s) => s.warmthPeriod); const [selectedRegion, setSelectedRegion] = useState('all'); const serverRegions = useVisitReviewRegions(); - const { data: serverWarmths, error: reviewsError } = useVisitReviewFeed( - selectedRegion === 'all' ? undefined : selectedRegion, - ); + const { + data: serverWarmths, + loading: reviewsLoading, + error: reviewsError, + reload: reloadReviews, + } = useVisitReviewFeed(selectedRegion === 'all' ? undefined : selectedRegion); const regionOptions = useMemo( () => [ { id: 'all', label: '전국' }, @@ -441,11 +445,33 @@ export default function WarmthFeed() {
- {reviewsError && filteredReviews.length === 0 ? ( + {reviewsLoading && allWarmths.length === 0 ? ( + + ) : reviewsError && filteredReviews.length === 0 ? ( + 다시 시도 + + } /> ) : filteredReviews.length === 0 ? (