Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8747654
[perf] 일반 페이지 라우트를 lazy()로 분할
You-Hyuk Oct 7, 2026
0c54aea
[perf] 관리자 라우트를 lazy()로 분할
You-Hyuk Oct 7, 2026
13b3225
[perf] SignupPage의 효과 없는 authApi 동적 import 제거
You-Hyuk Oct 7, 2026
4088b03
[perf] X 로고 PNG를 SVG로 교체
You-Hyuk Oct 7, 2026
46aee44
[fix] 캘린더 공연 목록의 없는 포스터 placeholder 참조 제거
You-Hyuk Oct 7, 2026
ff4f102
[perf] 아티스트 상세의 공연·발매 조회를 아티스트 조회와 병렬화
You-Hyuk Oct 7, 2026
7571ccb
[perf] 커뮤니티 목록 페이지 전환 시 이전 목록 유지
You-Hyuk Oct 7, 2026
61ec6aa
[test] DayConcertList 포스터 대체·날짜 필터 테스트 추가
You-Hyuk Oct 7, 2026
166ac4a
[perf] 화면 표시용 로고를 96px 이미지로 교체
You-Hyuk Oct 7, 2026
8bebbf8
[fix] 재배포 후 lazy 청크 로드 실패 시 한 번 새로고침
You-Hyuk Oct 7, 2026
1b65189
[fix] 화면 표시용 로고를 3배 해상도 대응 144px로 교체
You-Hyuk Oct 7, 2026
5af95e7
[perf] LCP 후보 포스터를 지연 로드하지 않고 우선 요청
You-Hyuk Oct 7, 2026
21d3c61
[perf] 목록 카드의 Spotify 앨범 커버를 300px 버전으로 요청
You-Hyuk Oct 7, 2026
1b1a362
[perf] Pretendard 폰트 CDN에 preconnect 추가
You-Hyuk Oct 7, 2026
d2f6486
[test] Spotify 유틸·ConcertCard priority 로드 테스트 추가
You-Hyuk Oct 7, 2026
e78c532
[perf] 공연 소개 이미지를 지연 로드
You-Hyuk Oct 7, 2026
999f798
[perf] 공연 소개 이미지를 대표 포스터 로드 후에 렌더링
You-Hyuk Oct 7, 2026
63b4801
[test] 공연 소개 이미지 지연 렌더링 테스트 추가
You-Hyuk Oct 7, 2026
cb9a5f7
[perf] 공연 목록 첫 화면 카드 4장의 포스터를 우선 요청
You-Hyuk Oct 7, 2026
7e23cad
[fix] 페이지 청크 로딩 중 푸터가 밀려나는 레이아웃 이동 수정
You-Hyuk Oct 7, 2026
2c18e36
[perf] 릴리즈 목록 첫 화면 카드 4장의 커버를 우선 요청
You-Hyuk Oct 7, 2026
2460da1
[perf] 아티스트 카드 이미지를 320px로 요청하고 첫 화면 카드를 우선 요청
You-Hyuk Oct 7, 2026
a80dd60
[perf] 아티스트·릴리즈 상세 대표 이미지를 우선 요청
You-Hyuk Oct 7, 2026
17c550f
[test] 아티스트 썸네일 유틸·카드 priority 로드 테스트 추가
You-Hyuk Oct 7, 2026
8ccdd11
[fix] 릴리즈 상세 로딩 스켈레톤의 레이아웃 이동 수정
You-Hyuk Oct 7, 2026
8458c7c
[ui] 릴리즈 상세 Spotify 버튼을 모바일에서 가운데 정렬
You-Hyuk Oct 7, 2026
32535ee
[perf] KOPIS 공연 이미지를 https 최종 주소로 바로 요청
You-Hyuk Oct 7, 2026
e2d021d
[test] KOPIS 이미지 https 변환 테스트 추가
You-Hyuk Oct 7, 2026
e82046e
[perf] 해시 빌드 파일(/assets)을 1년 immutable 캐시로 설정
You-Hyuk Oct 7, 2026
5af897e
[fix] 아티스트 상세 목록·D-day 배너 로딩 중 레이아웃 이동 수정
You-Hyuk Oct 7, 2026
825740c
[test] 아티스트 상세 목록 로딩·D-day 자리표시 테스트 추가
You-Hyuk Oct 7, 2026
ea26f1a
[style] 릴리즈 상세 미사용 스켈레톤 클래스 제거
You-Hyuk Oct 7, 2026
15a4280
[fix] 캘린더 공연 포스터 주소가 바뀌면 로드 실패 상태 초기화
You-Hyuk Oct 7, 2026
14606aa
[fix] 커뮤니티 목록 이전 목록 유지를 페이지 이동에만 적용
You-Hyuk Oct 7, 2026
02f40cc
[test] 캘린더 포스터 실패 상태 초기화·커뮤니티 목록 전환 테스트 추가
You-Hyuk Oct 7, 2026
1bfc9c7
[feat] ga_internal 파라미터로 운영자 기기 GA 수집 제외
You-Hyuk Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;">
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
integrity="sha384-GIdEBaqGN9mNkDkMkzMHW8EKUqtpPIe/sLj1X7DIrnc9uPtLROJgmuDlh+3rBw0j" />
Expand Down
Binary file added public/logo-144.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 36 additions & 29 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -61,6 +65,8 @@ function App() {
<script type="application/ld+json">{toSafeJsonLd(buildOrganizationJsonLd())}</script>
<Layout>
<ScrollToTop />
{/* 페이지 청크를 받는 동안 푸터가 화면 안으로 올라왔다가 밀려나지 않도록(CLS) 페이지 최소 높이만큼 비워 둔다 */}
<Suspense fallback={<div style={{ minHeight: 'calc(100svh - var(--header-height))' }} />}>
<Routes>
<Route path={ROUTES.AUTH_CALLBACK} element={<AuthCallbackPage />} />
<Route path={ROUTES.SIGNUP} element={<SignupPage />} />
Expand Down Expand Up @@ -106,6 +112,7 @@ function App() {

<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
<PageViewTracker />
<Analytics />
<SpeedInsights />
Expand Down
Binary file removed src/assets/x/logo-black.png
Binary file not shown.
1 change: 1 addition & 0 deletions src/assets/x/logo-black.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/assets/x/logo-white.png
Binary file not shown.
1 change: 1 addition & 0 deletions src/assets/x/logo-white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions src/components/artist/ArtistCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -66,10 +68,11 @@ function ArtistCard({ artist }) {
</div>
) : (
<img
src={imageUrl}
src={getSpotifyArtistThumbnail(imageUrl)}
alt={name}
className={styles.avatar}
loading="lazy"
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : 'auto'}
decoding="async"
onError={() => setImgFailed(true)}
/>
Expand Down
85 changes: 85 additions & 0 deletions src/components/artist/ArtistCard.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ArtistCard {...props} />
</MemoryRouter>
</QueryClientProvider>,
)
}

// 아바타 링크가 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()
})
})
31 changes: 22 additions & 9 deletions src/components/calendar/DayConcertList.jsx
Original file line number Diff line number Diff line change
@@ -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 = ['일', '월', '화', '수', '목', '금', '토']
Expand All @@ -29,6 +31,25 @@ function CheckIcon() {
)
}

// 포스터가 없거나 로드에 실패하면 이미지 없이 .poster 배경만 남긴다
function DayConcertPoster({ posterUrl, title }) {
const [imgFailed, setImgFailed] = useState(false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className={styles.poster}>
{posterUrl && !imgFailed && (
<img
src={toKopisHttpsUrl(posterUrl)}
alt={title}
loading="lazy"
decoding="async"
onError={() => setImgFailed(true)}
/>
)}
</div>
)
}

function DayConcertList({ selectedDate, events, onCalendarToggle }) {
if (!selectedDate) return null

Expand Down Expand Up @@ -72,15 +93,7 @@ function DayConcertList({ selectedDate, events, onCalendarToggle }) {
}}
/>

<div className={styles.poster}>
<img
src={ev.posterUrl || '/assets/poster-placeholder.png'}
alt={ev.title}
onError={(e) => {
e.target.src = '/assets/poster-placeholder.png'
}}
/>
</div>
<DayConcertPoster key={ev.posterUrl} posterUrl={ev.posterUrl} title={ev.title} />

<div className={styles.info}>
<p className={styles.artist}>
Expand Down
Loading
Loading