diff --git a/index.html b/index.html index e6c032e..ed4dac1 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ font-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https: http://www.kopis.or.kr; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://vitals.vercel-insights.com;"> + diff --git a/public/logo-144.png b/public/logo-144.png new file mode 100644 index 0000000..3f195f5 Binary files /dev/null and b/public/logo-144.png differ diff --git a/src/App.jsx b/src/App.jsx index 32f03de..4380e93 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,47 +1,51 @@ -import { useEffect } from 'react' +import { lazy, Suspense, useEffect } from 'react' import { Routes, Route } from 'react-router-dom' import { Analytics } from '@vercel/analytics/react' import { SpeedInsights } from '@vercel/speed-insights/react' import useAuthStore, { SESSION_HINT } from '@/stores/authStore' import { buildOrganizationJsonLd, buildWebsiteJsonLd, toSafeJsonLd } from '@/utils/structuredData' -import AdminLayout from '@/components/layout/AdminLayout' import AdminRoute from '@/components/layout/AdminRoute' import Layout from '@/components/layout/Layout' import PageViewTracker from '@/components/layout/PageViewTracker' import PrivateRoute from '@/components/layout/PrivateRoute' import ScrollToTop from '@/components/layout/ScrollToTop' -import AuthCallbackPage from '@/pages/AuthCallbackPage' -import SignupPage from '@/pages/SignupPage' -import ArtistDetailPage from '@/pages/ArtistDetailPage' -import ArtistsPage from '@/pages/ArtistsPage' -import CalendarPage from '@/pages/CalendarPage' -import ConcertDetailPage from '@/pages/ConcertDetailPage' -import ConcertsPage from '@/pages/ConcertsPage' -import ReleaseDetailPage from '@/pages/ReleaseDetailPage' -import ReleasesPage from '@/pages/ReleasesPage' -import PostDetailPage from '@/pages/PostDetailPage' -import PostsPage from '@/pages/PostsPage' -import PostWritePage from '@/pages/PostWritePage' -import NoticeDetailPage from '@/pages/NoticeDetailPage' import HomePage from '@/pages/HomePage' -import MyPage from '@/pages/MyPage' -import NotFoundPage from '@/pages/NotFoundPage' -import AdminPage from '@/pages/admin/AdminPage' -import AdminArtistFormPage from '@/pages/admin/AdminArtistFormPage' -import AdminConcertFormPage from '@/pages/admin/AdminConcertFormPage' -import AdminConcertCreatePage from '@/pages/admin/AdminConcertCreatePage' -import AdminInquiriesPage from '@/pages/admin/AdminInquiriesPage' -import AdminReportsPage from '@/pages/admin/AdminReportsPage' -import AdminNoticesPage from '@/pages/admin/AdminNoticesPage' -import AdminNoticeFormPage from '@/pages/admin/AdminNoticeFormPage' -import AdminPendingConcertsPage from '@/pages/admin/AdminPendingConcertsPage' -import AdminExcludedConcertsPage from '@/pages/admin/AdminExcludedConcertsPage' -import AdminPolicyPage from '@/pages/admin/AdminPolicyPage' -import PolicyPage from '@/pages/PolicyPage' import { TERMS_VERSIONS, PRIVACY_VERSIONS } from '@/constants/policy' import { ROUTES } from '@/constants/routes' +// 첫 진입 화면(HomePage)을 제외한 페이지는 라우트 단위로 분할해 초기 번들에서 뺀다 +const AuthCallbackPage = lazy(() => import('@/pages/AuthCallbackPage')) +const SignupPage = lazy(() => import('@/pages/SignupPage')) +const ArtistDetailPage = lazy(() => import('@/pages/ArtistDetailPage')) +const ArtistsPage = lazy(() => import('@/pages/ArtistsPage')) +const CalendarPage = lazy(() => import('@/pages/CalendarPage')) +const ConcertDetailPage = lazy(() => import('@/pages/ConcertDetailPage')) +const ConcertsPage = lazy(() => import('@/pages/ConcertsPage')) +const ReleaseDetailPage = lazy(() => import('@/pages/ReleaseDetailPage')) +const ReleasesPage = lazy(() => import('@/pages/ReleasesPage')) +const PostDetailPage = lazy(() => import('@/pages/PostDetailPage')) +const PostsPage = lazy(() => import('@/pages/PostsPage')) +const PostWritePage = lazy(() => import('@/pages/PostWritePage')) +const NoticeDetailPage = lazy(() => import('@/pages/NoticeDetailPage')) +const MyPage = lazy(() => import('@/pages/MyPage')) +const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) +const PolicyPage = lazy(() => import('@/pages/PolicyPage')) + +// 관리자 화면 — 일반 사용자 번들에 react-datepicker 등이 섞이지 않도록 분리 +const AdminLayout = lazy(() => import('@/components/layout/AdminLayout')) +const AdminPage = lazy(() => import('@/pages/admin/AdminPage')) +const AdminArtistFormPage = lazy(() => import('@/pages/admin/AdminArtistFormPage')) +const AdminConcertFormPage = lazy(() => import('@/pages/admin/AdminConcertFormPage')) +const AdminConcertCreatePage = lazy(() => import('@/pages/admin/AdminConcertCreatePage')) +const AdminInquiriesPage = lazy(() => import('@/pages/admin/AdminInquiriesPage')) +const AdminReportsPage = lazy(() => import('@/pages/admin/AdminReportsPage')) +const AdminNoticesPage = lazy(() => import('@/pages/admin/AdminNoticesPage')) +const AdminNoticeFormPage = lazy(() => import('@/pages/admin/AdminNoticeFormPage')) +const AdminPendingConcertsPage = lazy(() => import('@/pages/admin/AdminPendingConcertsPage')) +const AdminExcludedConcertsPage = lazy(() => import('@/pages/admin/AdminExcludedConcertsPage')) +const AdminPolicyPage = lazy(() => import('@/pages/admin/AdminPolicyPage')) + function App() { const clearUser = useAuthStore((s) => s.clearUser) @@ -61,6 +65,8 @@ function App() { + {/* 페이지 청크를 받는 동안 푸터가 화면 안으로 올라왔다가 밀려나지 않도록(CLS) 페이지 최소 높이만큼 비워 둔다 */} + }> } /> } /> @@ -106,6 +112,7 @@ function App() { } /> + diff --git a/src/assets/x/logo-black.png b/src/assets/x/logo-black.png deleted file mode 100644 index 2c2d7e8..0000000 Binary files a/src/assets/x/logo-black.png and /dev/null differ diff --git a/src/assets/x/logo-black.svg b/src/assets/x/logo-black.svg new file mode 100644 index 0000000..b7b3f5a --- /dev/null +++ b/src/assets/x/logo-black.svg @@ -0,0 +1 @@ + diff --git a/src/assets/x/logo-white.png b/src/assets/x/logo-white.png deleted file mode 100644 index 2609e58..0000000 Binary files a/src/assets/x/logo-white.png and /dev/null differ diff --git a/src/assets/x/logo-white.svg b/src/assets/x/logo-white.svg new file mode 100644 index 0000000..42eea08 --- /dev/null +++ b/src/assets/x/logo-white.svg @@ -0,0 +1 @@ + diff --git a/src/components/artist/ArtistCard.jsx b/src/components/artist/ArtistCard.jsx index e47b0cd..803635e 100644 --- a/src/components/artist/ArtistCard.jsx +++ b/src/components/artist/ArtistCard.jsx @@ -10,9 +10,11 @@ import { ROUTES } from '@/constants/routes' import { getArtistColor } from '@/utils/artistColor' import { followArtist, unfollowArtist } from '@/services/artistApi' import { trackEvent } from '@/utils/analytics' +import { getSpotifyArtistThumbnail } from '@/utils/spotify' import styles from './ArtistCard.module.css' -function ArtistCard({ artist }) { +// priority: 첫 화면에 바로 보이는 카드(LCP 후보)는 지연 로드하지 않고 먼저 받는다 +function ArtistCard({ artist, priority = false }) { const { id, name, koreanName, imageUrl, hasUpcomingConcert, spotifyUrl } = artist const [isFollowing, setIsFollowing] = useState(artist.isFollowing) const [prevIsFollowingProp, setPrevIsFollowingProp] = useState(artist.isFollowing) @@ -66,10 +68,11 @@ function ArtistCard({ artist }) { ) : ( {name} setImgFailed(true)} /> diff --git a/src/components/artist/ArtistCard.test.jsx b/src/components/artist/ArtistCard.test.jsx new file mode 100644 index 0000000..bbe3fb1 --- /dev/null +++ b/src/components/artist/ArtistCard.test.jsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import useAuthStore from '@/stores/authStore' +import useLoginModalStore from '@/stores/loginModalStore' +import ArtistCard from './ArtistCard' + +vi.mock('@/services/artistApi', () => ({ + followArtist: vi.fn(), + unfollowArtist: vi.fn(), +})) + +const HASH = 'f1e2d3c4b5a60718293a4b5c' + +function makeArtist(overrides = {}) { + return { + id: 1, + name: 'YOASOBI', + koreanName: '요아소비', + imageUrl: `https://i.scdn.co/image/ab6761610000e5eb${HASH}`, + hasUpcomingConcert: false, + spotifyUrl: null, + isFollowing: false, + ...overrides, + } +} + +function renderCard(props) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +// 아바타 링크가 aria-hidden이라 접근성 트리에서 제외되므로 hidden: true로 조회한다 +function getAvatar(name) { + return screen.getByRole('img', { name, hidden: true }) +} + +beforeEach(() => { + useAuthStore.setState(useAuthStore.getInitialState(), true) + useLoginModalStore.setState(useLoginModalStore.getInitialState(), true) + localStorage.clear() +}) + +describe('ArtistCard', () => { + it('640px Spotify 이미지면 320px 버전으로 렌더링', () => { + const artist = makeArtist() + renderCard({ artist }) + + expect(getAvatar(artist.name)).toHaveAttribute('src', `https://i.scdn.co/image/ab67616100005174${HASH}`) + }) + + it('priority를 지정하지 않으면 이미지를 지연 로드', () => { + const artist = makeArtist() + renderCard({ artist }) + + const avatar = getAvatar(artist.name) + + expect(avatar).toHaveAttribute('loading', 'lazy') + expect(avatar).toHaveAttribute('fetchpriority', 'auto') + }) + + it('priority를 지정하면 이미지를 우선 로드', () => { + const artist = makeArtist() + renderCard({ artist, priority: true }) + + const avatar = getAvatar(artist.name) + + expect(avatar).toHaveAttribute('loading', 'eager') + expect(avatar).toHaveAttribute('fetchpriority', 'high') + }) + + it.each([false, true])('imageUrl이 없으면 priority가 %s여도 이미지 없음', (priority) => { + const artist = makeArtist({ imageUrl: null }) + renderCard({ artist, priority }) + + expect(screen.queryByRole('img', { name: artist.name, hidden: true })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/calendar/DayConcertList.jsx b/src/components/calendar/DayConcertList.jsx index fa5e7f1..1d6fae2 100644 --- a/src/components/calendar/DayConcertList.jsx +++ b/src/components/calendar/DayConcertList.jsx @@ -1,9 +1,11 @@ +import { useState } from 'react' import { Link } from 'react-router-dom' import ArtistAliasName from '@/components/artist/ArtistAliasName' import { ROUTES } from '@/constants/routes' import { CONCERT_STATUS_COLOR, CONCERT_STATUS_LABEL } from '@/constants/concert' import { formatDate } from '@/utils/date' +import { toKopisHttpsUrl } from '@/utils/kopis' import styles from './DayConcertList.module.css' const WEEKDAY_NAMES = ['일', '월', '화', '수', '목', '금', '토'] @@ -29,6 +31,25 @@ function CheckIcon() { ) } +// 포스터가 없거나 로드에 실패하면 이미지 없이 .poster 배경만 남긴다 +function DayConcertPoster({ posterUrl, title }) { + const [imgFailed, setImgFailed] = useState(false) + + return ( +
+ {posterUrl && !imgFailed && ( + {title} setImgFailed(true)} + /> + )} +
+ ) +} + function DayConcertList({ selectedDate, events, onCalendarToggle }) { if (!selectedDate) return null @@ -72,15 +93,7 @@ function DayConcertList({ selectedDate, events, onCalendarToggle }) { }} /> -
- {ev.title} { - e.target.src = '/assets/poster-placeholder.png' - }} - /> -
+

diff --git a/src/components/calendar/DayConcertList.test.jsx b/src/components/calendar/DayConcertList.test.jsx new file mode 100644 index 0000000..d739ebf --- /dev/null +++ b/src/components/calendar/DayConcertList.test.jsx @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import DayConcertList from './DayConcertList' + +const SELECTED_DATE = new Date(2026, 8, 26) // 2026-09-26 (토) + +function makeConcert(overrides = {}) { + return { + concertId: 1, + type: 'CONCERT', + title: 'YOASOBI ASIA TOUR 2026 in Seoul', + artists: [{ artistId: 1, name: 'YOASOBI', koreanName: '요아소비' }], + startDate: '2026-09-26', + endDate: '2026-09-27', + venue: 'KSPO DOME', + status: 'UPCOMING', + color: '#1565C0', + posterUrl: 'https://example.com/posters/yoasobi.jpg', + inMyCalendar: false, + ...overrides, + } +} + +function listElement(props = {}) { + return ( + + + + ) +} + +function renderList(props = {}) { + return render(listElement(props)) +} + +describe('DayConcertList', () => { + describe('렌더링 조건', () => { + it('selectedDate가 없으면 아무것도 렌더하지 않음', () => { + const { container } = renderList({ selectedDate: null, events: [makeConcert()] }) + + expect(container).toBeEmptyDOMElement() + }) + + it('선택 날짜를 월·일·요일로 표시', () => { + renderList() + + expect(screen.getByRole('heading', { name: /9월 26일\s*토요일/ })).toBeInTheDocument() + }) + + it('이벤트가 없으면 빈 상태 문구 표시', () => { + renderList({ events: [] }) + + expect(screen.getByText('이 날의 공연이 없습니다.')).toBeInTheDocument() + expect(screen.getByText('0건')).toBeInTheDocument() + }) + }) + + describe('날짜 필터', () => { + it.each([ + ['시작일', '2026-09-26', '2026-09-28'], + ['기간 중간', '2026-09-25', '2026-09-27'], + ['종료일', '2026-09-24', '2026-09-26'], + ])('선택 날짜가 공연 %s이면 표시', (_, startDate, endDate) => { + renderList({ events: [makeConcert({ startDate, endDate })] }) + + expect(screen.getByRole('link', { name: /YOASOBI ASIA TOUR/ })).toBeInTheDocument() + }) + + it('endDate가 없으면 startDate 당일만 표시', () => { + renderList({ events: [makeConcert({ startDate: '2026-09-26', endDate: null })] }) + + expect(screen.getByRole('link', { name: /YOASOBI ASIA TOUR/ })).toBeInTheDocument() + }) + + it.each([ + ['이전에 끝난', '2026-09-20', '2026-09-25'], + ['이후에 시작하는', '2026-09-27', '2026-09-28'], + ])('선택 날짜 %s 공연은 제외', (_, startDate, endDate) => { + renderList({ events: [makeConcert({ startDate, endDate })] }) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText('이 날의 공연이 없습니다.')).toBeInTheDocument() + }) + + it('TICKETING은 공연 기간과 무관하게 ticketOpenAt 날짜가 같으면 표시', () => { + renderList({ + events: [makeConcert({ + type: 'TICKETING', + startDate: '2026-11-01', + endDate: '2026-11-02', + ticketOpenAt: '2026-09-26T20:00:00', + })], + }) + + expect(screen.getByRole('link', { name: /YOASOBI ASIA TOUR/ })).toBeInTheDocument() + expect(screen.getByText('티켓팅 오픈')).toBeInTheDocument() + }) + + it('TICKETING은 공연 기간에 포함돼도 ticketOpenAt 날짜가 다르면 제외', () => { + renderList({ + events: [makeConcert({ + type: 'TICKETING', + startDate: '2026-09-26', + endDate: '2026-09-27', + ticketOpenAt: '2026-09-01T20:00:00', + })], + }) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('해당 날짜 이벤트 건수를 표시', () => { + renderList({ + events: [ + makeConcert({ concertId: 1 }), + makeConcert({ concertId: 2, title: 'Ado WORLD TOUR 2026', artists: [{ artistId: 2, name: 'Ado' }] }), + makeConcert({ concertId: 3, title: 'Aimer Live in Seoul', startDate: '2026-10-10', endDate: '2026-10-10' }), + ], + }) + + expect(screen.getByText('2건')).toBeInTheDocument() + expect(screen.getAllByRole('link')).toHaveLength(2) + }) + + it('항목 링크는 공연 상세 경로를 가리킴', () => { + renderList({ events: [makeConcert({ concertId: 42 })] }) + + expect(screen.getByRole('link', { name: /YOASOBI ASIA TOUR/ })).toHaveAttribute('href', '/concerts/42') + }) + }) + + describe('포스터', () => { + it('posterUrl이 있으면 공연 제목을 alt로 lazy 로딩 이미지 렌더', () => { + renderList({ events: [makeConcert()] }) + + const img = screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' }) + expect(img).toHaveAttribute('src', 'https://example.com/posters/yoasobi.jpg') + expect(img).toHaveAttribute('loading', 'lazy') + expect(img).toHaveAttribute('decoding', 'async') + }) + + it('posterUrl이 http KOPIS 주소면 https://kopis.or.kr 주소로 이미지 요청', () => { + renderList({ events: [makeConcert({ posterUrl: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg' })] }) + + const img = screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' }) + expect(img).toHaveAttribute('src', 'https://kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg') + }) + + it('posterUrl이 null이면 이미지를 렌더하지 않음', () => { + renderList({ events: [makeConcert({ posterUrl: null })] }) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('이미지 로드가 실패하면 이미지를 제거', () => { + renderList({ events: [makeConcert()] }) + + fireEvent.error(screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' })) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it.each([ + ['posterUrl이 null이면', { posterUrl: null }, false], + ['이미지 로드가 실패하면', {}, true], + ])('%s placeholder 이미지로 대체하지 않음', (_, overrides, triggerError) => { + const { container } = renderList({ events: [makeConcert(overrides)] }) + + if (triggerError) fireEvent.error(screen.getByRole('img')) + + expect(container.innerHTML).not.toContain('poster-placeholder') + }) + + it('같은 이벤트의 posterUrl이 바뀌면 로드 실패 상태를 초기화해 새 이미지 렌더', () => { + const { rerender } = renderList({ events: [makeConcert()] }) + fireEvent.error(screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' })) + expect(screen.queryByRole('img')).not.toBeInTheDocument() + + rerender(listElement({ events: [makeConcert({ posterUrl: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg' })] })) + + expect(screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' })) + .toHaveAttribute('src', 'https://kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg') + }) + + it('posterUrl이 같으면 다시 렌더해도 로드 실패 상태 유지', () => { + const { rerender } = renderList({ events: [makeConcert()] }) + fireEvent.error(screen.getByRole('img', { name: 'YOASOBI ASIA TOUR 2026 in Seoul' })) + + rerender(listElement({ events: [makeConcert({ inMyCalendar: true })] })) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + }) + + describe('캘린더 버튼', () => { + it.each([ + [false, '내 캘린더에 추가'], + [true, '내 캘린더에서 제거'], + ])('inMyCalendar가 %s이면 aria-label은 %s', (inMyCalendar, label) => { + renderList({ events: [makeConcert({ inMyCalendar })] }) + + expect(screen.getByRole('button', { name: label })).toBeInTheDocument() + }) + + it('클릭하면 해당 이벤트로 onCalendarToggle 호출', async () => { + const user = userEvent.setup() + const onCalendarToggle = vi.fn() + const concert = makeConcert() + renderList({ events: [concert], onCalendarToggle }) + + await user.click(screen.getByRole('button', { name: '내 캘린더에 추가' })) + + expect(onCalendarToggle).toHaveBeenCalledTimes(1) + expect(onCalendarToggle).toHaveBeenCalledWith(concert) + }) + }) +}) diff --git a/src/components/concert/ConcertCard.jsx b/src/components/concert/ConcertCard.jsx index ae53ad9..78da11b 100644 --- a/src/components/concert/ConcertCard.jsx +++ b/src/components/concert/ConcertCard.jsx @@ -7,6 +7,7 @@ import StarRating from '@/components/ui/StarRating' import { ROUTES } from '@/constants/routes' import { formatDate } from '@/utils/date' import { getArtistColor } from '@/utils/artistColor' +import { toKopisHttpsUrl } from '@/utils/kopis' import styles from './ConcertCard.module.css' function getTicketDday(ticketOpenAt) { @@ -22,7 +23,8 @@ function getTicketDday(ticketOpenAt) { return `D-${diff}` } -function ConcertCard({ concert }) { +// priority: 첫 화면에 바로 보이는 카드(LCP 후보)는 지연 로드하지 않고 먼저 받는다 +function ConcertCard({ concert, priority = false }) { const { id, posterUrl, artists = [], title, startDate, endDate, venue, status, ticketOpenAt, averageRating } = concert const [imgFailed, setImgFailed] = useState(false) const showPlaceholder = !posterUrl || imgFailed @@ -48,10 +50,11 @@ function ConcertCard({ concert }) {

) : ( {title} setImgFailed(true)} /> diff --git a/src/components/concert/ConcertCard.test.jsx b/src/components/concert/ConcertCard.test.jsx new file mode 100644 index 0000000..c6c4045 --- /dev/null +++ b/src/components/concert/ConcertCard.test.jsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import ConcertCard from './ConcertCard' + +function makeConcert(overrides = {}) { + return { + id: 1, + posterUrl: 'https://example.com/poster/yoasobi.jpg', + artists: [{ artistId: 1, name: 'YOASOBI' }], + title: 'YOASOBI ASIA TOUR 2026 IN SEOUL', + startDate: '2026-09-26', + endDate: '2026-09-27', + venue: 'KSPO DOME', + status: 'UPCOMING', + ticketOpenAt: null, + averageRating: null, + ...overrides, + } +} + +function renderCard(props) { + return render( + + + , + ) +} + +describe('ConcertCard', () => { + it('priority를 지정하지 않으면 포스터를 지연 로드', () => { + const concert = makeConcert() + renderCard({ concert }) + + const poster = screen.getByRole('img', { name: concert.title }) + + expect(poster).toHaveAttribute('loading', 'lazy') + expect(poster).toHaveAttribute('fetchpriority', 'auto') + }) + + it('priority를 지정하면 포스터를 우선 로드', () => { + const concert = makeConcert() + renderCard({ concert, priority: true }) + + const poster = screen.getByRole('img', { name: concert.title }) + + expect(poster).toHaveAttribute('loading', 'eager') + expect(poster).toHaveAttribute('fetchpriority', 'high') + }) + + it('posterUrl이 http KOPIS 주소면 https://kopis.or.kr 주소로 포스터 요청', () => { + const concert = makeConcert({ posterUrl: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg' }) + renderCard({ concert }) + + const poster = screen.getByRole('img', { name: concert.title }) + + expect(poster).toHaveAttribute('src', 'https://kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg') + }) + + it.each([false, true])('posterUrl이 없으면 priority가 %s여도 포스터 이미지 없음', (priority) => { + const concert = makeConcert({ posterUrl: null }) + renderCard({ concert, priority }) + + expect(screen.queryByRole('img', { name: concert.title })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/release/ReleaseCard.jsx b/src/components/release/ReleaseCard.jsx index 55c5cfb..9d02f31 100644 --- a/src/components/release/ReleaseCard.jsx +++ b/src/components/release/ReleaseCard.jsx @@ -6,7 +6,7 @@ import SpotifyIcon from '@/components/ui/SpotifyIcon' import StarRating from '@/components/ui/StarRating' import { ROUTES } from '@/constants/routes' import { getArtistColor } from '@/utils/artistColor' -import { getSpotifyAlbumUrl } from '@/utils/spotify' +import { getSpotifyAlbumUrl, getSpotifyCoverThumbnail } from '@/utils/spotify' import styles from './ReleaseCard.module.css' const RELEASE_TYPE_COLOR = { @@ -21,7 +21,8 @@ function isNewRelease(dateStr) { return (Date.now() - d.getTime()) / 86400000 <= 30 } -function ReleaseCard({ release }) { +// priority: 첫 화면에 바로 보이는 카드(LCP 후보)는 지연 로드하지 않고 먼저 받는다 +function ReleaseCard({ release, priority = false }) { const { id, coverUrl, artistName, artistKoreanName, title, releaseDate, type, spotifyId, averageRating } = release const [imgFailed, setImgFailed] = useState(false) const showPlaceholder = !coverUrl || imgFailed @@ -43,10 +44,11 @@ function ReleaseCard({ release }) { ) : ( {title} setImgFailed(true)} /> diff --git a/src/components/release/ReleaseCard.test.jsx b/src/components/release/ReleaseCard.test.jsx new file mode 100644 index 0000000..8e72251 --- /dev/null +++ b/src/components/release/ReleaseCard.test.jsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import ReleaseCard from './ReleaseCard' + +function makeRelease(overrides = {}) { + return { + id: 1, + coverUrl: 'https://i.scdn.co/image/ab67616d0000b273a1b2c3d4e5f60718293a4b5c', + artistName: 'YOASOBI', + artistKoreanName: '요아소비', + title: 'THE BOOK 3', + releaseDate: '2026-09-26', + type: 'Album', + spotifyId: null, + averageRating: null, + ...overrides, + } +} + +function renderCard(props) { + return render( + + + , + ) +} + +describe('ReleaseCard', () => { + it('priority를 지정하지 않으면 커버를 지연 로드', () => { + const release = makeRelease() + renderCard({ release }) + + const cover = screen.getByRole('img', { name: release.title }) + + expect(cover).toHaveAttribute('loading', 'lazy') + expect(cover).toHaveAttribute('fetchpriority', 'auto') + }) + + it('priority를 지정하면 커버를 우선 로드', () => { + const release = makeRelease() + renderCard({ release, priority: true }) + + const cover = screen.getByRole('img', { name: release.title }) + + expect(cover).toHaveAttribute('loading', 'eager') + expect(cover).toHaveAttribute('fetchpriority', 'high') + }) + + it.each([false, true])('coverUrl이 없으면 priority가 %s여도 커버 이미지 없음', (priority) => { + const release = makeRelease({ coverUrl: null }) + renderCard({ release, priority }) + + expect(screen.queryByRole('img', { name: release.title })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/ui/Logo.jsx b/src/components/ui/Logo.jsx index 73cee3a..e4cd99b 100644 --- a/src/components/ui/Logo.jsx +++ b/src/components/ui/Logo.jsx @@ -4,7 +4,7 @@ function Logo({ size = 'md' }) { return ( { + try { + const last = Number(sessionStorage.getItem(CHUNK_RELOAD_KEY)) + if (Date.now() - last < 10_000) return + sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now())) + } catch { + return + } + event.preventDefault() + window.location.reload() +}) + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/src/pages/ArtistDetailPage.jsx b/src/pages/ArtistDetailPage.jsx index 32123ce..482c783 100644 --- a/src/pages/ArtistDetailPage.jsx +++ b/src/pages/ArtistDetailPage.jsx @@ -112,12 +112,11 @@ function ArtistDetailPage() { const { data: concertsData, isLoading: concertsLoading, isPlaceholderData: concertsIsPlaceholder } = useQuery({ queryKey: ['artist-concerts', artistId, concertTab, concertPage], queryFn: () => getArtistConcerts(artistId, { tab: concertTab, page: concertPage - 1, size: CONCERT_PAGE_SIZE }), - enabled: !!artist, placeholderData: (prev) => prev, }) // D-day 전용 — 탭·페이지와 무관하게 항상 가장 빠른 예정 공연 1건만 조회 - const { data: ddayConcertsData } = useQuery({ + const { data: ddayConcertsData, isLoading: ddayLoading } = useQuery({ queryKey: ['artist-concerts-dday', artistId], queryFn: () => getArtistConcerts(artistId, { tab: 'upcoming', page: 0, size: 1 }), enabled: !!artist?.hasUpcomingConcert, @@ -126,7 +125,6 @@ function ArtistDetailPage() { const { data: releasesData, isLoading: releasesLoading, isPlaceholderData: releasesIsPlaceholder } = useQuery({ queryKey: ['artist-releases', artistId, releasePage], queryFn: () => getArtistReleases(artistId, { page: releasePage - 1, size: RELEASE_PAGE_SIZE }), - enabled: !!artist, placeholderData: (prev) => prev, }) @@ -229,6 +227,9 @@ function ArtistDetailPage() { const hasNoDiscography = !releasesLoading && !releasesIsPlaceholder && releases.length === 0 const hasNoConcertHistory = !concertsLoading && !concertsIsPlaceholder && concertTab === 'all' && concerts.length === 0 const showCombinedEmpty = hasNoDiscography && hasNoConcertHistory + // 목록이 도착하며 높이가 바뀌어 아래 영역이 밀리지 않도록(CLS) 두 목록이 모두 올 때까지 하나의 스켈레톤으로 대신하고, + // 아래 영역(관련 게시글·문의·출처)은 숨겨 둔다. 숨겨도 마운트는 해서 관련 게시글 요청은 함께 시작한다. + const sectionsLoading = releasesLoading || concertsLoading return ( <> @@ -263,6 +264,7 @@ function ArtistDetailPage() { src={imageUrl} alt={name} className={styles.avatar} + fetchPriority="high" onError={() => setImgFailed(true)} /> )} @@ -329,7 +331,19 @@ function ArtistDetailPage() { - {/* D-day 배너 */} + {/* D-day 배너 — 예정 공연이 있으면 배너 데이터가 오기 전에도 같은 높이의 자리를 잡아 둔다(CLS) */} + {ddayLoading && ( + + )} {showDday && (
@@ -346,7 +360,16 @@ function ArtistDetailPage() { )} {/* 디스코그래피 · 내한 공연 내역이 둘 다 없으면 통합 안내로 압축 */} - {showCombinedEmpty ? ( + {sectionsLoading ? ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ ) : showCombinedEmpty ? (
0 && {releasesTotalElements}} - {releasesLoading ? ( -
- ) : releases.length > 0 ? ( + {releases.length > 0 ? ( <>
{releases.map((rel) => { @@ -478,9 +499,7 @@ function ArtistDetailPage() { ))}
- {concertsLoading ? ( -
- ) : concerts.length > 0 ? ( + {concerts.length > 0 ? ( <>
{concerts.map((concert) => ( @@ -509,6 +528,7 @@ function ArtistDetailPage() { )} +
{/* 관련 게시글 */} @@ -530,6 +550,7 @@ function ArtistDetailPage() { linkHref="https://creativecommons.org/licenses/by-nc-sa/3.0/" linkText="CC BY-NC-SA 3.0" /> +
diff --git a/src/pages/ArtistDetailPage.module.css b/src/pages/ArtistDetailPage.module.css index c471103..f86c73a 100644 --- a/src/pages/ArtistDetailPage.module.css +++ b/src/pages/ArtistDetailPage.module.css @@ -49,6 +49,36 @@ .skeletonMd { width: 70%; } .skeletonLg { width: 55%; height: 24px; } +/* 디스코그래피·공연 목록 로딩 자리 */ +.skeletonSectionTitle { + width: 120px; + height: 20px; + margin-bottom: 1rem; +} + +.skeletonRowList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.skeletonRow { + height: 44px; + border-radius: var(--radius-md); + background: linear-gradient(90deg, var(--color-surface) 25%, var(--color-surface-hover) 50%, var(--color-surface) 75%); + background-size: 800px 100%; + animation: shimmer 1.4s infinite linear; +} + +/* 목록 로딩 중 숨기는 아래 영역. 보일 때는 래퍼가 레이아웃에 끼지 않게 한다 */ +.deferred { + display: none; +} + +.deferredShown { + display: contents; +} + .inner { max-width: var(--max-width); margin: 0 auto; @@ -269,6 +299,11 @@ transition: background-color 0.15s, border-color 0.15s; } +/* 배너 데이터가 오기 전 자리표시 — 같은 구조로 높이만 맞춘다 */ +.ddayPlaceholder { + pointer-events: none; +} + .ddayBanner:hover { background: color-mix(in srgb, var(--color-accent) 13%, var(--color-surface)); border-color: color-mix(in srgb, var(--color-accent) 40%, transparent); diff --git a/src/pages/ArtistDetailPage.test.jsx b/src/pages/ArtistDetailPage.test.jsx new file mode 100644 index 0000000..156d48a --- /dev/null +++ b/src/pages/ArtistDetailPage.test.jsx @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import ArtistDetailPage from './ArtistDetailPage' +import styles from './ArtistDetailPage.module.css' +import { getArtist, getArtistConcerts, getArtistReleases } from '@/services/artistApi' +import useAuthStore from '@/stores/authStore' + +vi.mock('@/services/artistApi', () => ({ + getArtist: vi.fn(), + getArtistConcerts: vi.fn(), + getArtistReleases: vi.fn(), + followArtist: vi.fn(), + unfollowArtist: vi.fn(), +})) + +// 자체 API 호출을 하는 하위 컴포넌트는 제외. 관련 게시글은 로딩 중 마운트 여부를 확인하려고 표식만 렌더링 +vi.mock('@/components/post/RelatedPostsSection', () => ({ + default: () =>
, +})) +vi.mock('@/components/ui/InquiryModal', () => ({ default: () => null })) + +const ARTIST = { + id: 1, + name: 'YOASOBI', + koreanName: '요아소비', + imageUrl: null, + hasUpcomingConcert: false, + followersCount: 0, + links: [], + isFollowing: false, +} + +const NEXT_CONCERT = { + id: 10, + title: 'YOASOBI ASIA TOUR 2026 in Seoul', + startDate: '2099-09-26', + endDate: '2099-09-27', + venue: 'KSPO DOME', + status: 'UPCOMING', +} + +const EMPTY_PAGE = { content: [], totalElements: 0, totalPages: 1 } + +function createDeferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +// 한쪽 목록 resolve가 React Query 상태·렌더링까지 반영되도록 대기 +function flushPromises() { + return act(() => new Promise((r) => setTimeout(r, 0))) +} + +let releasesDeferred +let concertsDeferred +let ddayDeferred + +function renderPage(route = '/artists/1') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + } /> + + + , + ) +} + +function mockArtist(overrides = {}) { + getArtist.mockResolvedValue({ ...ARTIST, ...overrides }) +} + +async function waitForArtist() { + await screen.findByRole('heading', { level: 1 }) +} + +function getDeferredArea() { + // 관련 게시글·문의·출처를 감싸는 래퍼 (inquiryRow의 부모) + return screen.getByRole('button', { name: '아티스트 정보 문의' }).parentElement.parentElement +} + +function getDdayPlaceholder(container) { + return container.querySelector('div[aria-hidden="true"]') +} + +beforeEach(() => { + useAuthStore.setState(useAuthStore.getInitialState(), true) + releasesDeferred = createDeferred() + concertsDeferred = createDeferred() + ddayDeferred = createDeferred() + getArtistReleases.mockReturnValue(releasesDeferred.promise) + getArtistConcerts.mockImplementation((_id, params) => ( + params.tab === 'upcoming' && params.size === 1 ? ddayDeferred.promise : concertsDeferred.promise + )) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('ArtistDetailPage 목록 섹션 로딩', () => { + it('목록 로딩 중이면 디스코그래피·내한 공연 내역 대신 스켈레톤 섹션 하나만 렌더링', async () => { + mockArtist() + const { container } = renderPage() + + await waitForArtist() + + expect(screen.queryByRole('heading', { name: /디스코그래피/ })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: '내한 공연 내역' })).not.toBeInTheDocument() + expect(container.querySelectorAll('[aria-busy="true"]')).toHaveLength(1) + }) + + it('목록 로딩 중이면 아래 영역을 숨김 래퍼 안에 마운트', async () => { + mockArtist() + renderPage() + + await waitForArtist() + + expect(screen.getByRole('region', { name: '관련 게시글' })).toBeInTheDocument() + expect(getDeferredArea()).toHaveClass(styles.deferred) + expect(getDeferredArea()).not.toHaveClass(styles.deferredShown) + }) + + it('음반 목록만 도착하면 여전히 스켈레톤 유지', async () => { + mockArtist() + const { container } = renderPage() + await waitForArtist() + + releasesDeferred.resolve(EMPTY_PAGE) + await flushPromises() + + expect(screen.queryByRole('heading', { name: /디스코그래피/ })).not.toBeInTheDocument() + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect(getDeferredArea()).toHaveClass(styles.deferred) + }) + + it('공연 목록만 도착하면 여전히 스켈레톤 유지', async () => { + mockArtist() + const { container } = renderPage() + await waitForArtist() + + concertsDeferred.resolve(EMPTY_PAGE) + await flushPromises() + + expect(screen.queryByRole('heading', { name: '내한 공연 내역' })).not.toBeInTheDocument() + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect(getDeferredArea()).toHaveClass(styles.deferred) + }) + + it('두 목록이 모두 도착하면 섹션 heading 표시하고 스켈레톤 제거', async () => { + mockArtist() + const { container } = renderPage() + await waitForArtist() + + releasesDeferred.resolve({ + content: [{ id: 1, type: 'Album', title: 'THE BOOK', releaseDate: '2021-01-06', tracks: [] }], + totalElements: 1, + totalPages: 1, + }) + concertsDeferred.resolve(EMPTY_PAGE) + + expect(await screen.findByRole('heading', { name: /디스코그래피/ })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '내한 공연 내역' })).toBeInTheDocument() + expect(container.querySelector('[aria-busy="true"]')).not.toBeInTheDocument() + }) + + it('두 목록이 모두 도착하면 아래 영역 래퍼를 표시 상태로 전환', async () => { + mockArtist() + renderPage() + await waitForArtist() + + releasesDeferred.resolve(EMPTY_PAGE) + concertsDeferred.resolve(EMPTY_PAGE) + + await waitFor(() => expect(getDeferredArea()).toHaveClass(styles.deferredShown)) + expect(getDeferredArea()).not.toHaveClass(styles.deferred) + }) +}) + +describe('ArtistDetailPage D-day 자리표시', () => { + it('hasUpcomingConcert가 true이고 D-day 조회 중이면 링크가 아닌 aria-hidden 자리표시 렌더링', async () => { + mockArtist({ hasUpcomingConcert: true }) + const { container } = renderPage() + + await waitForArtist() + + expect(getDdayPlaceholder(container)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /다음 내한 공연/ })).not.toBeInTheDocument() + }) + + it('hasUpcomingConcert가 true이면 예정 공연 1건만 D-day 조회', async () => { + mockArtist({ hasUpcomingConcert: true }) + renderPage() + + await waitForArtist() + + await waitFor(() => ( + expect(getArtistConcerts).toHaveBeenCalledWith(1, { tab: 'upcoming', page: 0, size: 1 }) + )) + }) + + it('D-day 응답에 예정 공연이 있으면 자리표시 대신 다음 내한 공연 배너 링크 표시', async () => { + mockArtist({ hasUpcomingConcert: true }) + const { container } = renderPage() + await waitForArtist() + + ddayDeferred.resolve({ content: [NEXT_CONCERT], totalElements: 1, totalPages: 1 }) + + expect(await screen.findByRole('link', { name: /다음 내한 공연/ })).toHaveAttribute('href', '/concerts/10') + expect(getDdayPlaceholder(container)).not.toBeInTheDocument() + }) + + it('D-day 응답이 비어 있으면 자리표시와 배너 모두 미렌더링', async () => { + mockArtist({ hasUpcomingConcert: true }) + const { container } = renderPage() + await waitForArtist() + + ddayDeferred.resolve(EMPTY_PAGE) + + await waitFor(() => expect(getDdayPlaceholder(container)).not.toBeInTheDocument()) + expect(screen.queryByRole('link', { name: /다음 내한 공연/ })).not.toBeInTheDocument() + }) + + it('D-day 조회가 실패하면 자리표시와 배너 모두 미렌더링', async () => { + mockArtist({ hasUpcomingConcert: true }) + const { container } = renderPage() + await waitForArtist() + + ddayDeferred.reject(new Error('Network Error')) + + await waitFor(() => expect(getDdayPlaceholder(container)).not.toBeInTheDocument()) + expect(screen.queryByRole('link', { name: /다음 내한 공연/ })).not.toBeInTheDocument() + }) + + it('hasUpcomingConcert가 false이면 자리표시 미렌더링', async () => { + mockArtist({ hasUpcomingConcert: false }) + const { container } = renderPage() + + await waitForArtist() + + expect(getDdayPlaceholder(container)).not.toBeInTheDocument() + expect(getArtistConcerts).not.toHaveBeenCalledWith(1, { tab: 'upcoming', page: 0, size: 1 }) + }) +}) diff --git a/src/pages/ArtistsPage.jsx b/src/pages/ArtistsPage.jsx index 5204c4b..aeb6954 100644 --- a/src/pages/ArtistsPage.jsx +++ b/src/pages/ArtistsPage.jsx @@ -19,6 +19,8 @@ import { STATIC_PAGE_META } from '@/utils/pageMeta' import styles from './ArtistsPage.module.css' const PAGE_SIZE = 20 +// 첫 화면에 보이는 카드 수(모바일 2열 × 2행). 이 카드들의 이미지는 LCP 후보라 먼저 받는다. +const PRIORITY_CARD_COUNT = 4 const DEFAULT_SORT = 'sortName,asc' const SORT_OPTIONS = [ { value: DEFAULT_SORT, label: '이름순' }, @@ -233,8 +235,8 @@ function ArtistsPage() {
) : artists.length > 0 ? (
- {artists.map((artist) => ( - + {artists.map((artist, i) => ( + ))}
) : ( diff --git a/src/pages/ConcertDetailPage.jsx b/src/pages/ConcertDetailPage.jsx index a880948..6611d95 100644 --- a/src/pages/ConcertDetailPage.jsx +++ b/src/pages/ConcertDetailPage.jsx @@ -19,6 +19,7 @@ import { formatDate, formatDateTime } from '@/utils/date' import { buildConcertEventJsonLd, toSafeJsonLd } from '@/utils/structuredData' import { trackEvent } from '@/utils/analytics' import { buildConcertMeta } from '@/utils/pageMeta' +import { toKopisHttpsUrl } from '@/utils/kopis' import styles from './ConcertDetailPage.module.css' function PosterImage({ url, alt }) { @@ -26,9 +27,11 @@ function PosterImage({ url, alt }) { if (failed) return
return ( {alt} setFailed(true)} /> ) @@ -40,6 +43,7 @@ function ConcertDetailPage() { const queryClient = useQueryClient() const [posterFailed, setPosterFailed] = useState(false) + const [posterLoaded, setPosterLoaded] = useState(false) const [activeTab, setActiveTab] = useState('info') const [prevId, setPrevId] = useState(id) const [inquiryType, setInquiryType] = useState(null) @@ -50,6 +54,7 @@ function ConcertDetailPage() { if (id !== prevId) { setPrevId(id) setActiveTab('info') + setPosterLoaded(false) } const { data: concert, isLoading, isError, error } = useQuery({ @@ -157,6 +162,8 @@ function ConcertDetailPage() { const setlist = setlistData?.tracks ?? [] const setlistSourceUrl = setlistData?.sourceUrl const showPosterPlaceholder = !posterUrl || posterFailed + // 소개 이미지는 수 MB라 대표 포스터(LCP)와 동시에 받으면 포스터가 늦게 뜬다. 포스터가 끝난 뒤에 그린다. + const showIntroImages = showPosterPlaceholder || posterLoaded const dateRange = endDate && endDate !== startDate ? `${formatDate(startDate)} ~ ${formatDate(endDate)}` @@ -187,11 +194,12 @@ function ConcertDetailPage() {
) : ( {artistDisplayName} setPosterLoaded(true)} onError={() => setPosterFailed(true)} /> )} @@ -411,7 +419,7 @@ function ConcertDetailPage() {
)} - {activeTab === 'info' && ( + {activeTab === 'info' && showIntroImages && (
{imageUrls && imageUrls.length > 0 ? (
diff --git a/src/pages/ConcertDetailPage.test.jsx b/src/pages/ConcertDetailPage.test.jsx new file mode 100644 index 0000000..1ebbece --- /dev/null +++ b/src/pages/ConcertDetailPage.test.jsx @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom' +import ConcertDetailPage from './ConcertDetailPage' +import { getConcert } from '@/services/concertApi' + +vi.mock('@/services/concertApi', () => ({ + getConcert: vi.fn(), + getConcertSetlist: vi.fn(), +})) +vi.mock('@/services/calendarApi', () => ({ + addToCalendar: vi.fn(), + removeFromCalendar: vi.fn(), +})) + +// 소개 이미지 로드 순서와 무관한 하위 컴포넌트는 자체 API 호출을 하므로 제외 +vi.mock('@/components/rating/RatingSection', () => ({ default: () => null })) +vi.mock('@/components/post/RelatedPostsSection', () => ({ default: () => null })) +vi.mock('@/components/ui/InquiryModal', () => ({ default: () => null })) + +const CONCERT_1 = { + id: 1, + title: 'YOASOBI ASIA TOUR 2026 in Seoul', + posterUrl: 'https://example.com/posters/yoasobi.jpg', + imageUrls: [ + 'https://example.com/intro/yoasobi-1.jpg', + 'https://example.com/intro/yoasobi-2.jpg', + ], + artists: [{ artistId: 1, name: 'YOASOBI', koreanName: '요아소비' }], + startDate: '2026-09-26', + endDate: '2026-09-27', + venue: 'KSPO DOME', + status: 'UPCOMING', + price: null, + isInCalendar: false, + ticketLinks: [], + ticketOpenAt: null, + averageRating: null, + ratingCount: 0, +} + +const CONCERT_2 = { + ...CONCERT_1, + id: 2, + title: 'Ado WORLD TOUR 2026 in Seoul', + posterUrl: 'https://example.com/posters/ado.jpg', + imageUrls: ['https://example.com/intro/ado-1.jpg'], + artists: [{ artistId: 2, name: 'Ado', koreanName: '아도' }], +} + +const CONCERTS = { 1: CONCERT_1, 2: CONCERT_2 } + +function ConcertNavigator() { + const navigate = useNavigate() + return +} + +function renderPage(route = '/concerts/1') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + } /> + + + , + ) +} + +function mockConcert(overrides = {}) { + getConcert.mockImplementation((id) => Promise.resolve({ ...CONCERTS[id], ...overrides })) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('ConcertDetailPage 소개 이미지 지연 렌더링', () => { + it('포스터 load 전이면 소개 이미지 미렌더링', async () => { + mockConcert() + renderPage() + + await screen.findByRole('img', { name: 'YOASOBI' }) + + expect(screen.queryByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` })).not.toBeInTheDocument() + }) + + it('포스터 load 전이면 소개 이미지가 없어도 빈 문구 미렌더링', async () => { + mockConcert({ imageUrls: [] }) + renderPage() + + await screen.findByRole('img', { name: 'YOASOBI' }) + + expect(screen.queryByText('공연 정보가 존재하지 않습니다')).not.toBeInTheDocument() + }) + + it('포스터가 load되면 소개 이미지 렌더링', async () => { + mockConcert() + renderPage() + const poster = await screen.findByRole('img', { name: 'YOASOBI' }) + + fireEvent.load(poster) + + expect(screen.getByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` })).toBeInTheDocument() + expect(screen.getByRole('img', { name: `${CONCERT_1.title} 공연 정보 2` })).toBeInTheDocument() + }) + + it('포스터가 load되고 소개 이미지가 없으면 빈 문구 렌더링', async () => { + mockConcert({ imageUrls: [] }) + renderPage() + const poster = await screen.findByRole('img', { name: 'YOASOBI' }) + + fireEvent.load(poster) + + expect(screen.getByText('공연 정보가 존재하지 않습니다')).toBeInTheDocument() + }) + + it('포스터 로드에 실패하면 플레이스홀더로 바뀌고 소개 이미지 즉시 렌더링', async () => { + mockConcert() + renderPage() + const poster = await screen.findByRole('img', { name: 'YOASOBI' }) + + fireEvent.error(poster) + + expect(screen.queryByRole('img', { name: 'YOASOBI' })).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` })).toBeInTheDocument() + }) + + it('posterUrl이 없으면 처음부터 소개 이미지 렌더링', async () => { + mockConcert({ posterUrl: null }) + renderPage() + + expect(await screen.findByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` })).toBeInTheDocument() + }) + + it('소개 이미지에는 loading="lazy" 적용', async () => { + mockConcert({ posterUrl: null }) + renderPage() + + const introImage = await screen.findByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` }) + + expect(introImage).toHaveAttribute('loading', 'lazy') + }) + + it('다른 공연으로 이동하면 새 포스터 load 전까지 새 소개 이미지 미렌더링', async () => { + const user = userEvent.setup() + mockConcert() + renderPage() + fireEvent.load(await screen.findByRole('img', { name: 'YOASOBI' })) + + await user.click(screen.getByRole('button', { name: '다른 공연으로 이동' })) + await screen.findByRole('img', { name: 'Ado' }) + + expect(screen.queryByRole('img', { name: `${CONCERT_2.title} 공연 정보 1` })).not.toBeInTheDocument() + }) + + it('다른 공연으로 이동한 뒤 새 포스터가 load되면 새 소개 이미지 렌더링', async () => { + const user = userEvent.setup() + mockConcert() + renderPage() + fireEvent.load(await screen.findByRole('img', { name: 'YOASOBI' })) + await user.click(screen.getByRole('button', { name: '다른 공연으로 이동' })) + const newPoster = await screen.findByRole('img', { name: 'Ado' }) + + fireEvent.load(newPoster) + + expect(screen.getByRole('img', { name: `${CONCERT_2.title} 공연 정보 1` })).toBeInTheDocument() + }) +}) + +describe('ConcertDetailPage KOPIS 이미지 주소', () => { + it('posterUrl이 http KOPIS 주소면 https://kopis.or.kr 주소로 포스터 요청', async () => { + mockConcert({ posterUrl: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg' }) + renderPage() + + const poster = await screen.findByRole('img', { name: 'YOASOBI' }) + + expect(poster).toHaveAttribute('src', 'https://kopis.or.kr/upload/pfmPoster/PF_PF123456.jpg') + }) + + it('imageUrls가 http KOPIS 주소면 https://kopis.or.kr 주소로 소개 이미지 요청', async () => { + mockConcert({ imageUrls: ['http://www.kopis.or.kr/upload/pfmIntroImage/PF_PF123456_0.jpg'] }) + renderPage() + const poster = await screen.findByRole('img', { name: 'YOASOBI' }) + + fireEvent.load(poster) + + expect(screen.getByRole('img', { name: `${CONCERT_1.title} 공연 정보 1` })) + .toHaveAttribute('src', 'https://kopis.or.kr/upload/pfmIntroImage/PF_PF123456_0.jpg') + }) +}) diff --git a/src/pages/ConcertsPage.jsx b/src/pages/ConcertsPage.jsx index f18c1d6..e8f3252 100644 --- a/src/pages/ConcertsPage.jsx +++ b/src/pages/ConcertsPage.jsx @@ -21,6 +21,8 @@ import styles from './ConcertsPage.module.css' const STATUS_FILTERS = ['ALL', 'UPCOMING', 'ONGOING', 'ENDED', 'CANCELLED'] const ITEMS_PER_PAGE = 20 +// 첫 화면에 보이는 카드 수(모바일 2열 × 2행). 이 카드들의 포스터는 LCP 후보라 먼저 받는다. +const PRIORITY_CARD_COUNT = 4 function getConcertEmptyMessage(urlQuery, statusParam, effectiveFollowedOnly) { if (urlQuery && statusParam) return `'${urlQuery}' · ${CONCERT_STATUS_LABEL[statusParam]} 검색 결과가 없습니다.` @@ -230,8 +232,8 @@ function ConcertsPage() {
) : concerts.length > 0 ? (
- {concerts.map((concert) => ( - + {concerts.map((concert, i) => ( + ))}
) : ( diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index b53f2d1..9212adf 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -14,6 +14,7 @@ import useAuthStore from '@/stores/authStore' import useLoginModalStore from '@/stores/loginModalStore' import { getArtistColor } from '@/utils/artistColor' import { formatDate } from '@/utils/date' +import { getSpotifyCoverThumbnail } from '@/utils/spotify' import styles from './HomePage.module.css' @@ -120,9 +121,11 @@ function AlbumCard({ item }) { > {showImg && ( {item.title} setImgFailed(true)} /> )} @@ -225,7 +228,7 @@ function HomePage() { ) : (
{popularConcerts.slice(0, 3).map((concert) => ( - + ))}
)} diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx index 07901d4..4d3e0be 100644 --- a/src/pages/PostsPage.jsx +++ b/src/pages/PostsPage.jsx @@ -103,12 +103,14 @@ function PostsPage() { return () => clearTimeout(timer) }, [inputValue, setSearchParams]) + const listKey = isSearching + ? ['search', urlQuery] + : isPopular + ? ['posts', 'popular-board'] + : ['posts', selectedCategory] + const { data, isLoading } = useQuery({ - queryKey: isSearching - ? ['search', urlQuery, currentPage] - : isPopular - ? ['posts', 'popular-board', currentPage] - : ['posts', selectedCategory, currentPage], + queryKey: [...listKey, currentPage], queryFn: () => (isSearching ? getSearch({ q: urlQuery, page: currentPage - 1, size: ITEMS_PER_PAGE }) : isPopular @@ -119,6 +121,9 @@ function PostsPage() { size: ITEMS_PER_PAGE, })), enabled: !isQueryTooShort, + // 페이지만 바뀔 때 이전 목록을 유지한다. 카테고리·검색어가 바뀌면 다른 목록이 비치지 않도록 스켈레톤을 보인다 + placeholderData: (prev, prevQuery) => + listKey.every((part, i) => part === prevQuery?.queryKey[i]) ? prev : undefined, }) const posts = data?.content ?? [] diff --git a/src/pages/PostsPage.test.jsx b/src/pages/PostsPage.test.jsx new file mode 100644 index 0000000..3c6f594 --- /dev/null +++ b/src/pages/PostsPage.test.jsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import PostsPage from './PostsPage' +import { getPosts, getSearch } from '@/services/postApi' +import useAuthStore from '@/stores/authStore' + +vi.mock('@/services/postApi', () => ({ + getPosts: vi.fn(), + getPopularBoardPosts: vi.fn(), + getSearch: vi.fn(), +})) +vi.mock('@/utils/analytics', () => ({ trackEvent: vi.fn() })) + +// 자체 API를 호출하는 하위 컴포넌트는 제외 +vi.mock('@/components/post/PinnedNotices', () => ({ default: () => null })) +vi.mock('@/components/post/sidebar/PostsSidebar', () => ({ default: () => null })) +// 스켈레톤은 접근 가능한 표식이 없어 식별용 표식으로 대체 +vi.mock('@/components/post/PostListItemSkeleton', () => ({ + default: () =>
, +})) + +function createDeferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +// resolve가 React Query 상태·렌더링까지 반영되도록 대기 +function flushPromises() { + return act(() => new Promise((r) => setTimeout(r, 0))) +} + +function createPost(id, title, category = 'FREE') { + return { + id, + category, + title, + authorNickname: '테스트유저', + recommendCount: 0, + viewCount: 0, + createdAt: '2026-09-26T10:00:00', + entityTags: [], + } +} + +function createPage(posts, totalPages = 1) { + return { content: posts, totalElements: posts.length, totalPages } +} + +function renderPage(route = '/community') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +function querySkeletons() { + return screen.queryAllByRole('status', { name: '게시글 불러오는 중' }) +} + +beforeEach(() => { + useAuthStore.setState(useAuthStore.getInitialState(), true) + // Pagination이 matchMedia로 모바일 여부를 판단 + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))) + vi.stubGlobal('scrollTo', vi.fn()) +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('PostsPage 목록 전환 중 표시', () => { + it('페이지만 바뀌면 다음 페이지 응답 대기 중에도 이전 목록을 유지하고 스켈레톤 미표시', async () => { + const user = userEvent.setup() + const page2Deferred = createDeferred() + getPosts.mockImplementation(({ page }) => ( + page === 0 + ? Promise.resolve(createPage([createPost(1, '1페이지 첫 글')], 2)) + : page2Deferred.promise + )) + renderPage() + await screen.findByText('1페이지 첫 글') + + await user.click(screen.getByRole('button', { name: '2페이지' })) + await flushPromises() + + expect(getPosts).toHaveBeenLastCalledWith({ category: undefined, page: 1, size: 10 }) + expect(screen.getByText('1페이지 첫 글')).toBeInTheDocument() + expect(querySkeletons()).toHaveLength(0) + }) + + it('페이지만 바뀐 뒤 응답이 오면 다음 페이지 글로 교체', async () => { + const user = userEvent.setup() + const page2Deferred = createDeferred() + getPosts.mockImplementation(({ page }) => ( + page === 0 + ? Promise.resolve(createPage([createPost(1, '1페이지 첫 글')], 2)) + : page2Deferred.promise + )) + renderPage() + await screen.findByText('1페이지 첫 글') + await user.click(screen.getByRole('button', { name: '2페이지' })) + + page2Deferred.resolve(createPage([createPost(11, '2페이지 첫 글')], 2)) + + expect(await screen.findByText('2페이지 첫 글')).toBeInTheDocument() + expect(screen.queryByText('1페이지 첫 글')).not.toBeInTheDocument() + }) + + it('카테고리가 바뀌면 응답 대기 중 이전 목록 대신 스켈레톤 표시', async () => { + const user = userEvent.setup() + const freeDeferred = createDeferred() + getPosts.mockImplementation(({ category }) => ( + category === 'FREE' + ? freeDeferred.promise + : Promise.resolve(createPage([createPost(1, '전체 목록 글', 'INFO')])) + )) + renderPage() + await screen.findByText('전체 목록 글') + + await user.click(screen.getByRole('tab', { name: '자유' })) + await flushPromises() + + expect(screen.queryByText('전체 목록 글')).not.toBeInTheDocument() + expect(querySkeletons().length).toBeGreaterThan(0) + }) + + it('카테고리가 바뀐 뒤 응답이 오면 스켈레톤 대신 새 카테고리 글 표시', async () => { + const user = userEvent.setup() + const freeDeferred = createDeferred() + getPosts.mockImplementation(({ category }) => ( + category === 'FREE' + ? freeDeferred.promise + : Promise.resolve(createPage([createPost(1, '전체 목록 글', 'INFO')])) + )) + renderPage() + await screen.findByText('전체 목록 글') + await user.click(screen.getByRole('tab', { name: '자유' })) + + freeDeferred.resolve(createPage([createPost(2, '자유 게시판 글')])) + + expect(await screen.findByText('자유 게시판 글')).toBeInTheDocument() + expect(querySkeletons()).toHaveLength(0) + }) + + it('검색어가 바뀌면 응답 대기 중 이전 검색 결과 대신 스켈레톤 표시', async () => { + const user = userEvent.setup() + const nextDeferred = createDeferred() + getSearch.mockImplementation(({ q }) => ( + q === 'YOASOBI' + ? Promise.resolve(createPage([createPost(1, 'YOASOBI 내한 후기', 'REVIEW')])) + : nextDeferred.promise + )) + renderPage('/community?q=YOASOBI') + await screen.findByText('YOASOBI 내한 후기') + + await user.type(screen.getByRole('textbox', { name: '게시글 검색' }), ' 2026') + + expect(await screen.findAllByRole('status', { name: '게시글 불러오는 중' })).not.toHaveLength(0) + expect(getSearch).toHaveBeenLastCalledWith({ q: 'YOASOBI 2026', page: 0, size: 10 }) + expect(screen.queryByText('YOASOBI 내한 후기')).not.toBeInTheDocument() + }) +}) diff --git a/src/pages/ReleaseDetailPage.jsx b/src/pages/ReleaseDetailPage.jsx index 33683a8..0da1c06 100644 --- a/src/pages/ReleaseDetailPage.jsx +++ b/src/pages/ReleaseDetailPage.jsx @@ -77,14 +77,19 @@ function ReleaseDetailPage() { if (isLoading) return (
-
+ {/* 데이터 도착 후 아래 본문이 밀리지 않도록(CLS) 히어로 각 요소의 높이를 실제 화면과 맞춘다. + key: 실제 히어로가 스켈레톤 DOM을 재사용하면 첫 div가 heroBlur로 바뀌며 이동으로 집계되므로 새로 그리게 한다 */} +
+
-
-
-
+
+
+
+
+
@@ -157,6 +162,7 @@ function ReleaseDetailPage() { src={coverUrl} alt={`${title} 커버`} className={styles.coverImg} + fetchPriority="high" onError={() => setCoverFailed(true)} /> )} diff --git a/src/pages/ReleaseDetailPage.module.css b/src/pages/ReleaseDetailPage.module.css index 3d692f6..f5ef278 100644 --- a/src/pages/ReleaseDetailPage.module.css +++ b/src/pages/ReleaseDetailPage.module.css @@ -28,8 +28,27 @@ } .skeletonXs { width: 30%; height: 10px; } -.skeletonSm { width: 50%; } -.skeletonLg { width: 80%; height: 22px; } + +/* 히어로 스켈레톤 — 높이(height + margin)를 실제 요소와 맞춘다: 아티스트 24 · 제목 41(모바일 30) · 메타 22 · Spotify 버튼 41 · 별점 98 */ +.heroSkeleton .backLink { + color: var(--color-text-muted); +} + +.skeletonHeroArtist { width: 30%; height: 16px; margin: 4px 0; } +.skeletonHeroTitle { width: 70%; height: 30px; margin: 5.5px 0; } +.skeletonHeroMeta { width: 40%; height: 14px; margin: 4px 0; } + +.skeletonHeroButton { + width: 196px; + height: 41px; + border-radius: var(--radius-full); +} + +.skeletonHeroRating { + width: 60%; + max-width: 360px; + height: 98px; +} .skeletonTrackList { display: flex; @@ -358,10 +377,25 @@ font-size: 1.625rem; } + /* 실제 heroInfo는 내용 너비로 가운데 정렬되므로, % 너비 자리표시가 0으로 줄지 않게 폭을 채우고 가운데로 모은다 */ + .heroSkeleton .heroInfo { + width: 100%; + align-items: center; + } + + .skeletonHeroTitle { + height: 22px; + margin: 4px 0; + } + .heroMeta { justify-content: center; } + .spotifyBtn { + align-self: center; + } + .body { padding: 1.5rem 1rem 3rem; } diff --git a/src/pages/ReleasesPage.jsx b/src/pages/ReleasesPage.jsx index f05ec63..a81ccf0 100644 --- a/src/pages/ReleasesPage.jsx +++ b/src/pages/ReleasesPage.jsx @@ -22,6 +22,8 @@ import styles from './ReleasesPage.module.css' const MAIN_TYPES = ['Album', 'Single'] const TYPE_FILTERS = ['전체', ...MAIN_TYPES] const PAGE_SIZE = 20 +// 첫 화면에 보이는 카드 수(모바일 2열 × 2행). 이 카드들의 이미지는 LCP 후보라 먼저 받는다. +const PRIORITY_CARD_COUNT = 4 function ReleasesPage() { const [searchParams, setSearchParams] = useSearchParams() @@ -205,8 +207,8 @@ function ReleasesPage() {
) : releases.length > 0 ? (
- {releases.map((release) => ( - + {releases.map((release, i) => ( + ))}
) : ( diff --git a/src/pages/SignupPage.jsx b/src/pages/SignupPage.jsx index be8f584..8934234 100644 --- a/src/pages/SignupPage.jsx +++ b/src/pages/SignupPage.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Navigate, useNavigate } from 'react-router-dom' import useAuthStore from '@/stores/authStore' -import { register, checkNickname } from '@/services/authApi' +import { register, checkNickname, getMe } from '@/services/authApi' import { ROUTES } from '@/constants/routes' import { LOGIN_REDIRECT_KEY } from '@/constants/auth' import { @@ -200,7 +200,6 @@ function SignupPage() { agreedMarketing: consents.agreedMarketing, }) setAccessToken(accessToken) - const { getMe } = await import('@/services/authApi') const user = await getMe() setUser(user) trackEvent('sign_up') diff --git a/src/utils/analytics.js b/src/utils/analytics.js index e34c11d..05910e3 100644 --- a/src/utils/analytics.js +++ b/src/utils/analytics.js @@ -1,4 +1,20 @@ -const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID +const INTERNAL_PARAM = 'ga_internal' +const INTERNAL_STORAGE_KEY = 'ga-internal' + +// 운영자 기기는 ?ga_internal=1로 한 번 접속하면 이후 GA를 로드하지 않는다(=0으로 해제). +// IP 기반 내부 트래픽 필터는 모바일처럼 IP가 자주 바뀌는 기기를 걸러내지 못한다. +function isInternalDevice() { + try { + const flag = new URLSearchParams(window.location.search).get(INTERNAL_PARAM) + if (flag === '1') localStorage.setItem(INTERNAL_STORAGE_KEY, '1') + if (flag === '0') localStorage.removeItem(INTERNAL_STORAGE_KEY) + return localStorage.getItem(INTERNAL_STORAGE_KEY) === '1' + } catch { + return false + } +} + +const GA_MEASUREMENT_ID = isInternalDevice() ? null : import.meta.env.VITE_GA_MEASUREMENT_ID // gtag.js는 dataLayer에 arguments 객체가 들어와야 명령으로 인식하고 일반 배열은 // 무시한다. rest 파라미터(...args)로 배열을 넣으면 config가 실행되지 않아 히트가 diff --git a/src/utils/analytics.test.js b/src/utils/analytics.test.js index f79ed16..aec21f3 100644 --- a/src/utils/analytics.test.js +++ b/src/utils/analytics.test.js @@ -210,6 +210,58 @@ describe('trackEvent', () => { }) }) +describe('내부 기기 제외', () => { + afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() + window.history.replaceState(null, '', '/') + }) + + it('?ga_internal=1로 접속하면 스크립트를 삽입하지 않음', async () => { + window.history.replaceState(null, '', '/?ga_internal=1') + const { initGA } = await loadAnalytics('G-TEST') + + initGA() + + expect(document.head.querySelector('script')).toBeNull() + }) + + it('?ga_internal=1로 한 번 접속하면 이후 파라미터 없이 접속해도 제외', async () => { + window.history.replaceState(null, '', '/?ga_internal=1') + await loadAnalytics('G-TEST') + window.history.replaceState(null, '', '/artists/1') + const { initGA, trackPageView, trackEvent } = await loadAnalytics('G-TEST') + + initGA() + trackPageView('/artists/1', 'YOASOBI | Coming') + trackEvent('search') + + expect(window.dataLayer).toBeUndefined() + }) + + it('?ga_internal=0으로 접속하면 제외를 해제', async () => { + window.history.replaceState(null, '', '/?ga_internal=1') + await loadAnalytics('G-TEST') + window.history.replaceState(null, '', '/?ga_internal=0') + const { initGA } = await loadAnalytics('G-TEST') + + initGA() + + expect(document.head.querySelector('script').src).toBe(GTAG_SRC) + }) + + it('localStorage 접근이 실패하면 GA를 그대로 로드', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError') + }) + const { initGA } = await loadAnalytics('G-TEST') + + initGA() + + expect(document.head.querySelector('script').src).toBe(GTAG_SRC) + }) +}) + describe('sanitizeSearch', () => { it.each([ ['q만 있으면 빈 문자열', '?q=yoasobi', ''], diff --git a/src/utils/kopis.js b/src/utils/kopis.js new file mode 100644 index 0000000..3c69ab1 --- /dev/null +++ b/src/utils/kopis.js @@ -0,0 +1,8 @@ +// KOPIS 포스터·소개 이미지는 http://www.kopis.or.kr 주소로 내려온다. https 페이지에서 그대로 쓰면 +// 혼합 콘텐츠로 자동 업그레이드된 뒤 https://www → https://kopis.or.kr 리다이렉트를 한 번 더 거친다. +// 최종 주소(https://kopis.or.kr)로 바로 요청한다. 다른 주소는 그대로 둔다. +const KOPIS_HTTP_ORIGIN = /^http:\/\/(www\.)?kopis\.or\.kr\// + +export function toKopisHttpsUrl(url) { + return url?.replace(KOPIS_HTTP_ORIGIN, 'https://kopis.or.kr/') +} diff --git a/src/utils/kopis.test.js b/src/utils/kopis.test.js new file mode 100644 index 0000000..3454249 --- /dev/null +++ b/src/utils/kopis.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { toKopisHttpsUrl } from './kopis' + +describe('toKopisHttpsUrl', () => { + it('http://www.kopis.or.kr 주소면 https://kopis.or.kr로 치환', () => { + expect(toKopisHttpsUrl('http://www.kopis.or.kr/upload/pfmPoster/PF_PF123456_260801_123456.jpg')) + .toBe('https://kopis.or.kr/upload/pfmPoster/PF_PF123456_260801_123456.jpg') + }) + + it('www가 없는 http://kopis.or.kr 주소면 https://kopis.or.kr로 치환', () => { + expect(toKopisHttpsUrl('http://kopis.or.kr/upload/pfmIntroImage/PF_PF123456_260801_0123456.jpg')) + .toBe('https://kopis.or.kr/upload/pfmIntroImage/PF_PF123456_260801_0123456.jpg') + }) + + it('쿼리 문자열이 있으면 경로와 쿼리를 보존', () => { + expect(toKopisHttpsUrl('http://www.kopis.or.kr/upload/pfmPoster/poster.gif?v=2&size=large')) + .toBe('https://kopis.or.kr/upload/pfmPoster/poster.gif?v=2&size=large') + }) + + it.each([ + 'https://kopis.or.kr/upload/pfmPoster/poster.jpg', + 'https://www.kopis.or.kr/upload/pfmPoster/poster.jpg', + ])('이미 https인 KOPIS 주소 %s면 그대로 반환', (url) => { + expect(toKopisHttpsUrl(url)).toBe(url) + }) + + it.each([ + 'http://example.com/posters/yoasobi.jpg', + 'https://example.com/posters/yoasobi.jpg', + 'http://example.com/http://www.kopis.or.kr/upload/poster.jpg', + 'http://cdn.example.com/kopis.or.kr/upload/poster.jpg', + ])('KOPIS가 아닌 출처 %s면 그대로 반환', (url) => { + expect(toKopisHttpsUrl(url)).toBe(url) + }) + + it.each([null, undefined])('URL이 %j이면 undefined', (url) => { + expect(toKopisHttpsUrl(url)).toBeUndefined() + }) + + it('URL이 빈 문자열이면 빈 문자열', () => { + expect(toKopisHttpsUrl('')).toBe('') + }) +}) diff --git a/src/utils/spotify.js b/src/utils/spotify.js index caeedf4..1c4b190 100644 --- a/src/utils/spotify.js +++ b/src/utils/spotify.js @@ -5,3 +5,20 @@ export function getSpotifyAlbumUrl(spotifyId) { export function getSpotifyTrackUrl(spotifyId) { return spotifyId ? `https://open.spotify.com/track/${spotifyId}` : null } + +// Spotify 앨범 커버는 URL 접두어로 크기가 정해진다 (640: ab67616d0000b273, 300: ab67616d00001e02). +// 목록 카드는 200px 안팎으로 표시되므로 300px 버전을 받는다. 접두어가 없는 URL은 그대로 둔다. +const SPOTIFY_COVER_640 = '/image/ab67616d0000b273' +const SPOTIFY_COVER_300 = '/image/ab67616d00001e02' + +export function getSpotifyCoverThumbnail(coverUrl) { + return coverUrl?.replace(SPOTIFY_COVER_640, SPOTIFY_COVER_300) +} + +// 아티스트 이미지도 같은 방식이다 (640: ab6761610000e5eb, 320: ab67616100005174). +const SPOTIFY_ARTIST_640 = '/image/ab6761610000e5eb' +const SPOTIFY_ARTIST_320 = '/image/ab67616100005174' + +export function getSpotifyArtistThumbnail(imageUrl) { + return imageUrl?.replace(SPOTIFY_ARTIST_640, SPOTIFY_ARTIST_320) +} diff --git a/src/utils/spotify.test.js b/src/utils/spotify.test.js new file mode 100644 index 0000000..a82a382 --- /dev/null +++ b/src/utils/spotify.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { getSpotifyAlbumUrl, getSpotifyArtistThumbnail, getSpotifyCoverThumbnail, getSpotifyTrackUrl } from './spotify' + +describe('getSpotifyAlbumUrl', () => { + it('id가 있으면 앨범 URL', () => { + expect(getSpotifyAlbumUrl('4yP0hdKOZPNshxUOjY0cZj')).toBe('https://open.spotify.com/album/4yP0hdKOZPNshxUOjY0cZj') + }) + + it.each([null, undefined, ''])('id가 %j이면 null', (id) => { + expect(getSpotifyAlbumUrl(id)).toBeNull() + }) +}) + +describe('getSpotifyTrackUrl', () => { + it('id가 있으면 트랙 URL', () => { + expect(getSpotifyTrackUrl('7MXVkk9YMctZqd1Srtv4MB')).toBe('https://open.spotify.com/track/7MXVkk9YMctZqd1Srtv4MB') + }) + + it.each([null, undefined, ''])('id가 %j이면 null', (id) => { + expect(getSpotifyTrackUrl(id)).toBeNull() + }) +}) + +describe('getSpotifyCoverThumbnail', () => { + const HASH = 'a1b2c3d4e5f60718293a4b5c' + + it('640px 커버 URL이면 300px 접두어로 치환', () => { + expect(getSpotifyCoverThumbnail(`https://i.scdn.co/image/ab67616d0000b273${HASH}`)) + .toBe(`https://i.scdn.co/image/ab67616d00001e02${HASH}`) + }) + + it('이미 300px 커버 URL이면 그대로 반환', () => { + const url = `https://i.scdn.co/image/ab67616d00001e02${HASH}` + + expect(getSpotifyCoverThumbnail(url)).toBe(url) + }) + + it('접두어가 없는 다른 출처 URL이면 그대로 반환', () => { + const url = 'https://coverartarchive.org/release/abc/front-500.jpg' + + expect(getSpotifyCoverThumbnail(url)).toBe(url) + }) + + it.each([null, undefined])('URL이 %j이면 undefined', (url) => { + expect(getSpotifyCoverThumbnail(url)).toBeUndefined() + }) +}) + +describe('getSpotifyArtistThumbnail', () => { + const HASH = 'f1e2d3c4b5a60718293a4b5c' + + it('640px 아티스트 이미지 URL이면 320px 접두어로 치환', () => { + expect(getSpotifyArtistThumbnail(`https://i.scdn.co/image/ab6761610000e5eb${HASH}`)) + .toBe(`https://i.scdn.co/image/ab67616100005174${HASH}`) + }) + + it('이미 320px 아티스트 이미지 URL이면 그대로 반환', () => { + const url = `https://i.scdn.co/image/ab67616100005174${HASH}` + + expect(getSpotifyArtistThumbnail(url)).toBe(url) + }) + + it('접두어가 없는 다른 출처 URL이면 그대로 반환', () => { + const url = 'https://example.com/artist/yoasobi.jpg' + + expect(getSpotifyArtistThumbnail(url)).toBe(url) + }) + + it.each([null, undefined])('URL이 %j이면 undefined', (url) => { + expect(getSpotifyArtistThumbnail(url)).toBeUndefined() + }) +}) diff --git a/vercel.json b/vercel.json index ae06c16..39d1c1c 100644 --- a/vercel.json +++ b/vercel.json @@ -9,6 +9,12 @@ { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] } ], "rewrites": [